From bc850337a06608bd281bcf929e5dd29be1b7ce4e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 16:53:55 +0000 Subject: [PATCH 001/109] Initial plan From 10d9f086c6019761855aa3bc0cccc6e737075ce3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 16:57:13 +0000 Subject: [PATCH 002/109] Add Windows context menu integration for grabbing text from images Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com> --- Tests/ContextMenuTests.cs | 63 ++++++++ Text-Grab/Pages/GeneralSettings.xaml | 18 +++ Text-Grab/Pages/GeneralSettings.xaml.cs | 39 +++++ Text-Grab/Properties/Settings.Designer.cs | 12 ++ Text-Grab/Properties/Settings.settings | 3 + Text-Grab/Utilities/ContextMenuUtilities.cs | 164 ++++++++++++++++++++ 6 files changed, 299 insertions(+) create mode 100644 Tests/ContextMenuTests.cs create mode 100644 Text-Grab/Utilities/ContextMenuUtilities.cs diff --git a/Tests/ContextMenuTests.cs b/Tests/ContextMenuTests.cs new file mode 100644 index 00000000..7f22cfd3 --- /dev/null +++ b/Tests/ContextMenuTests.cs @@ -0,0 +1,63 @@ +using Text_Grab.Utilities; + +namespace Tests; + +public class ContextMenuTests +{ + [Fact] + public void GetShellKeyPath_ReturnsCorrectPath_ForPngExtension() + { + // Arrange + string extension = ".png"; + string expectedPath = @"Software\Classes\SystemFileAssociations\.png\shell\Text-Grab.GrabText"; + + // Act - Use reflection to access private method for testing + var method = typeof(ContextMenuUtilities).GetMethod( + "GetShellKeyPath", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + + string? result = method?.Invoke(null, [extension]) as string; + + // Assert + Assert.Equal(expectedPath, result); + } + + [Fact] + public void GetShellKeyPath_ReturnsCorrectPath_ForJpgExtension() + { + // Arrange + string extension = ".jpg"; + string expectedPath = @"Software\Classes\SystemFileAssociations\.jpg\shell\Text-Grab.GrabText"; + + // Act - Use reflection to access private method for testing + var method = typeof(ContextMenuUtilities).GetMethod( + "GetShellKeyPath", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + + string? result = method?.Invoke(null, [extension]) as string; + + // Assert + Assert.Equal(expectedPath, result); + } + + [Fact] + public void ImageExtensions_ContainsExpectedFormats() + { + // Arrange - Use reflection to access private field + var field = typeof(ContextMenuUtilities).GetField( + "ImageExtensions", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + + string[]? extensions = field?.GetValue(null) as string[]; + + // Assert + Assert.NotNull(extensions); + Assert.Contains(".png", extensions); + Assert.Contains(".jpg", extensions); + Assert.Contains(".jpeg", extensions); + Assert.Contains(".bmp", extensions); + Assert.Contains(".gif", extensions); + Assert.Contains(".tiff", extensions); + Assert.Contains(".tif", extensions); + } +} diff --git a/Text-Grab/Pages/GeneralSettings.xaml b/Text-Grab/Pages/GeneralSettings.xaml index 0b5dbe15..d997b79d 100644 --- a/Text-Grab/Pages/GeneralSettings.xaml +++ b/Text-Grab/Pages/GeneralSettings.xaml @@ -225,6 +225,24 @@ + + + + + Add "Grab text with Text Grab" to right-click menu for image files + + + + Right-click on PNG, JPG, BMP, GIF, or TIFF files to quickly grab text. + + .Instance.DefaultSearcher = newDefault; } + + private void AddToContextMenuCheckBox_Checked(object sender, RoutedEventArgs e) + { + if (!settingsSet) + return; + + bool success = ContextMenuUtilities.AddToContextMenu(); + if (success) + { + DefaultSettings.AddToContextMenu = true; + DefaultSettings.Save(); + } + else + { + // Revert the checkbox if registration failed + AddToContextMenuCheckBox.IsChecked = false; + } + } + + private void AddToContextMenuCheckBox_Unchecked(object sender, RoutedEventArgs e) + { + if (!settingsSet) + return; + + ContextMenuUtilities.RemoveFromContextMenu(); + DefaultSettings.AddToContextMenu = false; + DefaultSettings.Save(); + } } diff --git a/Text-Grab/Properties/Settings.Designer.cs b/Text-Grab/Properties/Settings.Designer.cs index a22ca083..33a8986e 100644 --- a/Text-Grab/Properties/Settings.Designer.cs +++ b/Text-Grab/Properties/Settings.Designer.cs @@ -838,5 +838,17 @@ public bool PostGrabStayOpen { this["PostGrabStayOpen"] = value; } } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("False")] + public bool AddToContextMenu { + get { + return ((bool)(this["AddToContextMenu"])); + } + set { + this["AddToContextMenu"] = value; + } + } } } diff --git a/Text-Grab/Properties/Settings.settings b/Text-Grab/Properties/Settings.settings index e9eac4d3..52cb7cf6 100644 --- a/Text-Grab/Properties/Settings.settings +++ b/Text-Grab/Properties/Settings.settings @@ -206,5 +206,8 @@ False + + False + \ No newline at end of file diff --git a/Text-Grab/Utilities/ContextMenuUtilities.cs b/Text-Grab/Utilities/ContextMenuUtilities.cs new file mode 100644 index 00000000..988a92e9 --- /dev/null +++ b/Text-Grab/Utilities/ContextMenuUtilities.cs @@ -0,0 +1,164 @@ +using Microsoft.Win32; +using System; + +namespace Text_Grab.Utilities; + +/// +/// Utility class for managing Windows context menu integration. +/// Adds "Grab text with Text Grab" option to the right-click context menu for image files. +/// +internal static class ContextMenuUtilities +{ + private const string ContextMenuRegistryKeyName = "Text-Grab.GrabText"; + private const string ContextMenuDisplayText = "Grab text with Text Grab"; + + /// + /// Supported image file extensions for context menu integration. + /// + private static readonly string[] ImageExtensions = + [ + ".png", + ".jpg", + ".jpeg", + ".bmp", + ".gif", + ".tiff", + ".tif" + ]; + + /// + /// Adds Text Grab to the Windows context menu for image files. + /// This allows users to right-click on an image and select "Grab text with Text Grab". + /// + /// True if registration was successful, false otherwise. + public static bool AddToContextMenu() + { + string executablePath = FileUtilities.GetExePath(); + + if (string.IsNullOrEmpty(executablePath)) + return false; + + try + { + foreach (string extension in ImageExtensions) + { + RegisterContextMenuForExtension(extension, executablePath); + } + return true; + } + catch (Exception) + { + return false; + } + } + + /// + /// Removes Text Grab from the Windows context menu for image files. + /// + /// True if removal was successful, false otherwise. + public static bool RemoveFromContextMenu() + { + try + { + foreach (string extension in ImageExtensions) + { + UnregisterContextMenuForExtension(extension); + } + return true; + } + catch (Exception) + { + return false; + } + } + + /// + /// Checks if Text Grab is currently registered in the context menu. + /// + /// True if registered, false otherwise. + public static bool IsRegisteredInContextMenu() + { + try + { + // Check if at least one extension has the context menu registered + foreach (string extension in ImageExtensions) + { + string keyPath = GetShellKeyPath(extension); + using RegistryKey? key = Registry.CurrentUser.OpenSubKey(keyPath); + if (key is not null) + return true; + } + return false; + } + catch (Exception) + { + return false; + } + } + + /// + /// Registers the context menu entry for a specific file extension. + /// Uses the Shell registration approach under HKEY_CURRENT_USER for per-user installation. + /// + /// The file extension (e.g., ".png") + /// The path to the Text Grab executable + private static void RegisterContextMenuForExtension(string extension, string executablePath) + { + // Register under HKEY_CURRENT_USER\Software\Classes\ + // This approach works for per-user installation without requiring admin rights + string shellKeyPath = GetShellKeyPath(extension); + string commandKeyPath = $@"{shellKeyPath}\command"; + + // Create the shell key with display name + using (RegistryKey? shellKey = Registry.CurrentUser.CreateSubKey(shellKeyPath)) + { + if (shellKey is null) + return; + + shellKey.SetValue(string.Empty, ContextMenuDisplayText); + shellKey.SetValue("Icon", $"\"{executablePath}\""); + } + + // Create the command key with the executable path + using (RegistryKey? commandKey = Registry.CurrentUser.CreateSubKey(commandKeyPath)) + { + if (commandKey is null) + return; + + // %1 is replaced by Windows with the path to the file that was right-clicked + // --windowless flag will OCR and copy to clipboard without opening a window + commandKey.SetValue(string.Empty, $"\"{executablePath}\" \"%1\""); + } + } + + /// + /// Removes the context menu entry for a specific file extension. + /// + /// The file extension (e.g., ".png") + private static void UnregisterContextMenuForExtension(string extension) + { + string shellKeyPath = GetShellKeyPath(extension); + + try + { + // Delete the entire shell key and its subkeys + Registry.CurrentUser.DeleteSubKeyTree(shellKeyPath, throwOnMissingSubKey: false); + } + catch (Exception) + { + // Ignore errors during unregistration + } + } + + /// + /// Gets the registry path for the shell context menu key for a given extension. + /// + /// The file extension (e.g., ".png") + /// The registry key path + private static string GetShellKeyPath(string extension) + { + // Using SystemFileAssociations allows the context menu to work regardless of + // which application is associated with the file type + return $@"Software\Classes\SystemFileAssociations\{extension}\shell\{ContextMenuRegistryKeyName}"; + } +} From 11d0d893a068b3c6cac01b6ef9b7af746fa7cc08 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 17:00:55 +0000 Subject: [PATCH 003/109] Improve error handling and user feedback for context menu registration Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com> --- Text-Grab/Pages/GeneralSettings.xaml.cs | 11 +++++++- Text-Grab/Utilities/ContextMenuUtilities.cs | 30 ++++++++++++++++----- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/Text-Grab/Pages/GeneralSettings.xaml.cs b/Text-Grab/Pages/GeneralSettings.xaml.cs index 43e8e48b..0a45b639 100644 --- a/Text-Grab/Pages/GeneralSettings.xaml.cs +++ b/Text-Grab/Pages/GeneralSettings.xaml.cs @@ -395,7 +395,7 @@ private void AddToContextMenuCheckBox_Checked(object sender, RoutedEventArgs e) if (!settingsSet) return; - bool success = ContextMenuUtilities.AddToContextMenu(); + bool success = ContextMenuUtilities.AddToContextMenu(out string? errorMessage); if (success) { DefaultSettings.AddToContextMenu = true; @@ -404,7 +404,16 @@ private void AddToContextMenuCheckBox_Checked(object sender, RoutedEventArgs e) else { // Revert the checkbox if registration failed + settingsSet = false; AddToContextMenuCheckBox.IsChecked = false; + settingsSet = true; + + // Show error message to user + System.Windows.MessageBox.Show( + errorMessage ?? "Failed to add Text Grab to the context menu.", + "Context Menu Registration Failed", + MessageBoxButton.OK, + MessageBoxImage.Warning); } } diff --git a/Text-Grab/Utilities/ContextMenuUtilities.cs b/Text-Grab/Utilities/ContextMenuUtilities.cs index 988a92e9..c1621aa8 100644 --- a/Text-Grab/Utilities/ContextMenuUtilities.cs +++ b/Text-Grab/Utilities/ContextMenuUtilities.cs @@ -1,5 +1,6 @@ using Microsoft.Win32; using System; +using System.Diagnostics; namespace Text_Grab.Utilities; @@ -30,13 +31,18 @@ internal static class ContextMenuUtilities /// Adds Text Grab to the Windows context menu for image files. /// This allows users to right-click on an image and select "Grab text with Text Grab". /// + /// When the method returns false, contains an error message describing the failure. /// True if registration was successful, false otherwise. - public static bool AddToContextMenu() + public static bool AddToContextMenu(out string? errorMessage) { + errorMessage = null; string executablePath = FileUtilities.GetExePath(); if (string.IsNullOrEmpty(executablePath)) + { + errorMessage = "Could not determine the application executable path."; return false; + } try { @@ -46,8 +52,16 @@ public static bool AddToContextMenu() } return true; } - catch (Exception) + catch (UnauthorizedAccessException ex) + { + Debug.WriteLine($"Context menu registration failed due to permissions: {ex.Message}"); + errorMessage = "Permission denied. Please run Text Grab as administrator or check your registry permissions."; + return false; + } + catch (Exception ex) { + Debug.WriteLine($"Context menu registration failed: {ex.Message}"); + errorMessage = $"Failed to register context menu: {ex.Message}"; return false; } } @@ -66,8 +80,9 @@ public static bool RemoveFromContextMenu() } return true; } - catch (Exception) + catch (Exception ex) { + Debug.WriteLine($"Context menu unregistration failed: {ex.Message}"); return false; } } @@ -90,8 +105,9 @@ public static bool IsRegisteredInContextMenu() } return false; } - catch (Exception) + catch (Exception ex) { + Debug.WriteLine($"Context menu registration check failed: {ex.Message}"); return false; } } @@ -126,7 +142,6 @@ private static void RegisterContextMenuForExtension(string extension, string exe return; // %1 is replaced by Windows with the path to the file that was right-clicked - // --windowless flag will OCR and copy to clipboard without opening a window commandKey.SetValue(string.Empty, $"\"{executablePath}\" \"%1\""); } } @@ -144,9 +159,10 @@ private static void UnregisterContextMenuForExtension(string extension) // Delete the entire shell key and its subkeys Registry.CurrentUser.DeleteSubKeyTree(shellKeyPath, throwOnMissingSubKey: false); } - catch (Exception) + catch (Exception ex) { - // Ignore errors during unregistration + // Log but don't throw - we want to continue trying to remove other extensions + Debug.WriteLine($"Failed to unregister context menu for {extension}: {ex.Message}"); } } From 62d4e34575017720994190ee03c02a8e58fa959c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 17:03:29 +0000 Subject: [PATCH 004/109] Address code review feedback: improve error handling and test quality Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com> --- Tests/ContextMenuTests.cs | 51 ++++++++++----------- Text-Grab/AssemblyInfo.cs | 4 +- Text-Grab/Pages/GeneralSettings.xaml.cs | 12 ++++- Text-Grab/Utilities/ContextMenuUtilities.cs | 12 +++-- 4 files changed, 48 insertions(+), 31 deletions(-) diff --git a/Tests/ContextMenuTests.cs b/Tests/ContextMenuTests.cs index 7f22cfd3..8f5e1f17 100644 --- a/Tests/ContextMenuTests.cs +++ b/Tests/ContextMenuTests.cs @@ -11,12 +11,8 @@ public void GetShellKeyPath_ReturnsCorrectPath_ForPngExtension() string extension = ".png"; string expectedPath = @"Software\Classes\SystemFileAssociations\.png\shell\Text-Grab.GrabText"; - // Act - Use reflection to access private method for testing - var method = typeof(ContextMenuUtilities).GetMethod( - "GetShellKeyPath", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); - - string? result = method?.Invoke(null, [extension]) as string; + // Act + string result = ContextMenuUtilities.GetShellKeyPath(extension); // Assert Assert.Equal(expectedPath, result); @@ -29,35 +25,38 @@ public void GetShellKeyPath_ReturnsCorrectPath_ForJpgExtension() string extension = ".jpg"; string expectedPath = @"Software\Classes\SystemFileAssociations\.jpg\shell\Text-Grab.GrabText"; - // Act - Use reflection to access private method for testing - var method = typeof(ContextMenuUtilities).GetMethod( - "GetShellKeyPath", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); - - string? result = method?.Invoke(null, [extension]) as string; + // Act + string result = ContextMenuUtilities.GetShellKeyPath(extension); // Assert Assert.Equal(expectedPath, result); } [Fact] - public void ImageExtensions_ContainsExpectedFormats() + public void GetShellKeyPath_ReturnsCorrectPath_ForTiffExtension() { - // Arrange - Use reflection to access private field - var field = typeof(ContextMenuUtilities).GetField( - "ImageExtensions", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + // Arrange + string extension = ".tiff"; + string expectedPath = @"Software\Classes\SystemFileAssociations\.tiff\shell\Text-Grab.GrabText"; - string[]? extensions = field?.GetValue(null) as string[]; + // Act + string result = ContextMenuUtilities.GetShellKeyPath(extension); // Assert - Assert.NotNull(extensions); - Assert.Contains(".png", extensions); - Assert.Contains(".jpg", extensions); - Assert.Contains(".jpeg", extensions); - Assert.Contains(".bmp", extensions); - Assert.Contains(".gif", extensions); - Assert.Contains(".tiff", extensions); - Assert.Contains(".tif", extensions); + Assert.Equal(expectedPath, result); + } + + [Fact] + public void GetShellKeyPath_ReturnsConsistentFormat() + { + // Act + string pngPath = ContextMenuUtilities.GetShellKeyPath(".png"); + string jpgPath = ContextMenuUtilities.GetShellKeyPath(".jpg"); + + // Assert - Both should follow the same pattern + Assert.StartsWith(@"Software\Classes\SystemFileAssociations\", pngPath); + Assert.StartsWith(@"Software\Classes\SystemFileAssociations\", jpgPath); + Assert.EndsWith(@"\shell\Text-Grab.GrabText", pngPath); + Assert.EndsWith(@"\shell\Text-Grab.GrabText", jpgPath); } } diff --git a/Text-Grab/AssemblyInfo.cs b/Text-Grab/AssemblyInfo.cs index 262cb38c..5c6da42e 100644 --- a/Text-Grab/AssemblyInfo.cs +++ b/Text-Grab/AssemblyInfo.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using System.Runtime.Versioning; using System.Windows; using System.Windows.Media; @@ -11,4 +12,5 @@ ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located //(used if a resource is not found in the page, // app, or any theme specific resource dictionaries) -)] \ No newline at end of file +)] +[assembly: InternalsVisibleTo("Tests")] \ No newline at end of file diff --git a/Text-Grab/Pages/GeneralSettings.xaml.cs b/Text-Grab/Pages/GeneralSettings.xaml.cs index 0a45b639..6dd88377 100644 --- a/Text-Grab/Pages/GeneralSettings.xaml.cs +++ b/Text-Grab/Pages/GeneralSettings.xaml.cs @@ -422,8 +422,18 @@ private void AddToContextMenuCheckBox_Unchecked(object sender, RoutedEventArgs e if (!settingsSet) return; - ContextMenuUtilities.RemoveFromContextMenu(); + bool success = ContextMenuUtilities.RemoveFromContextMenu(); DefaultSettings.AddToContextMenu = false; DefaultSettings.Save(); + + if (!success) + { + // Show warning but don't revert - the setting should still be saved as disabled + System.Windows.MessageBox.Show( + "Some context menu entries could not be removed. They may be cleaned up manually in the Windows Registry.", + "Context Menu Removal Warning", + MessageBoxButton.OK, + MessageBoxImage.Warning); + } } } diff --git a/Text-Grab/Utilities/ContextMenuUtilities.cs b/Text-Grab/Utilities/ContextMenuUtilities.cs index c1621aa8..46d3322d 100644 --- a/Text-Grab/Utilities/ContextMenuUtilities.cs +++ b/Text-Grab/Utilities/ContextMenuUtilities.cs @@ -129,7 +129,10 @@ private static void RegisterContextMenuForExtension(string extension, string exe using (RegistryKey? shellKey = Registry.CurrentUser.CreateSubKey(shellKeyPath)) { if (shellKey is null) - return; + { + Debug.WriteLine($"Failed to create registry key: {shellKeyPath}"); + throw new InvalidOperationException($"Could not create registry key for {extension}"); + } shellKey.SetValue(string.Empty, ContextMenuDisplayText); shellKey.SetValue("Icon", $"\"{executablePath}\""); @@ -139,7 +142,10 @@ private static void RegisterContextMenuForExtension(string extension, string exe using (RegistryKey? commandKey = Registry.CurrentUser.CreateSubKey(commandKeyPath)) { if (commandKey is null) - return; + { + Debug.WriteLine($"Failed to create registry key: {commandKeyPath}"); + throw new InvalidOperationException($"Could not create command registry key for {extension}"); + } // %1 is replaced by Windows with the path to the file that was right-clicked commandKey.SetValue(string.Empty, $"\"{executablePath}\" \"%1\""); @@ -171,7 +177,7 @@ private static void UnregisterContextMenuForExtension(string extension) /// /// The file extension (e.g., ".png") /// The registry key path - private static string GetShellKeyPath(string extension) + internal static string GetShellKeyPath(string extension) { // Using SystemFileAssociations allows the context menu to work regardless of // which application is associated with the file type From f3327996b414cb3222f071442cc5c82ccb25674e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 17:04:30 +0000 Subject: [PATCH 005/109] Final improvements: consistent error handling for removal and proper setting state management Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com> --- Text-Grab/Pages/GeneralSettings.xaml.cs | 23 ++++++++++++++------- Text-Grab/Utilities/ContextMenuUtilities.cs | 11 +++++++++- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/Text-Grab/Pages/GeneralSettings.xaml.cs b/Text-Grab/Pages/GeneralSettings.xaml.cs index 6dd88377..5081eb20 100644 --- a/Text-Grab/Pages/GeneralSettings.xaml.cs +++ b/Text-Grab/Pages/GeneralSettings.xaml.cs @@ -422,16 +422,23 @@ private void AddToContextMenuCheckBox_Unchecked(object sender, RoutedEventArgs e if (!settingsSet) return; - bool success = ContextMenuUtilities.RemoveFromContextMenu(); - DefaultSettings.AddToContextMenu = false; - DefaultSettings.Save(); - - if (!success) + bool success = ContextMenuUtilities.RemoveFromContextMenu(out string? errorMessage); + + if (success) + { + DefaultSettings.AddToContextMenu = false; + DefaultSettings.Save(); + } + else { - // Show warning but don't revert - the setting should still be saved as disabled + // Revert the checkbox since removal failed - the context menu is still registered + settingsSet = false; + AddToContextMenuCheckBox.IsChecked = true; + settingsSet = true; + System.Windows.MessageBox.Show( - "Some context menu entries could not be removed. They may be cleaned up manually in the Windows Registry.", - "Context Menu Removal Warning", + errorMessage ?? "Some context menu entries could not be removed.", + "Context Menu Removal Failed", MessageBoxButton.OK, MessageBoxImage.Warning); } diff --git a/Text-Grab/Utilities/ContextMenuUtilities.cs b/Text-Grab/Utilities/ContextMenuUtilities.cs index 46d3322d..32c9e110 100644 --- a/Text-Grab/Utilities/ContextMenuUtilities.cs +++ b/Text-Grab/Utilities/ContextMenuUtilities.cs @@ -69,9 +69,11 @@ public static bool AddToContextMenu(out string? errorMessage) /// /// Removes Text Grab from the Windows context menu for image files. /// + /// When the method returns false, contains an error message describing the failure. /// True if removal was successful, false otherwise. - public static bool RemoveFromContextMenu() + public static bool RemoveFromContextMenu(out string? errorMessage) { + errorMessage = null; try { foreach (string extension in ImageExtensions) @@ -80,9 +82,16 @@ public static bool RemoveFromContextMenu() } return true; } + catch (UnauthorizedAccessException ex) + { + Debug.WriteLine($"Context menu unregistration failed due to permissions: {ex.Message}"); + errorMessage = "Permission denied. Some context menu entries could not be removed."; + return false; + } catch (Exception ex) { Debug.WriteLine($"Context menu unregistration failed: {ex.Message}"); + errorMessage = $"Failed to remove context menu entries: {ex.Message}"; return false; } } From bdc347694ae96ecb1918e4e2c5ffba2d2a0fcafa Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 31 Jan 2026 12:33:39 -0600 Subject: [PATCH 006/109] Clarify MessageBoxButton enum usage in MessageBox.Show Updated MessageBox.Show calls to explicitly use System.Windows.MessageBoxButton.OK for improved clarity and to avoid ambiguity. --- Text-Grab/Pages/GeneralSettings.xaml.cs | 4 ++-- Text-Grab/Views/SettingsWindow.xaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Text-Grab/Pages/GeneralSettings.xaml.cs b/Text-Grab/Pages/GeneralSettings.xaml.cs index 5081eb20..3cde4e03 100644 --- a/Text-Grab/Pages/GeneralSettings.xaml.cs +++ b/Text-Grab/Pages/GeneralSettings.xaml.cs @@ -412,7 +412,7 @@ private void AddToContextMenuCheckBox_Checked(object sender, RoutedEventArgs e) System.Windows.MessageBox.Show( errorMessage ?? "Failed to add Text Grab to the context menu.", "Context Menu Registration Failed", - MessageBoxButton.OK, + System.Windows.MessageBoxButton.OK, MessageBoxImage.Warning); } } @@ -439,7 +439,7 @@ private void AddToContextMenuCheckBox_Unchecked(object sender, RoutedEventArgs e System.Windows.MessageBox.Show( errorMessage ?? "Some context menu entries could not be removed.", "Context Menu Removal Failed", - MessageBoxButton.OK, + System.Windows.MessageBoxButton.OK, MessageBoxImage.Warning); } } diff --git a/Text-Grab/Views/SettingsWindow.xaml b/Text-Grab/Views/SettingsWindow.xaml index ff773443..46adc14e 100644 --- a/Text-Grab/Views/SettingsWindow.xaml +++ b/Text-Grab/Views/SettingsWindow.xaml @@ -114,7 +114,7 @@ - F + Date: Sat, 31 Jan 2026 18:39:23 +0000 Subject: [PATCH 007/109] Add Windows 11 modern context menu support for packaged apps and Open in Grab Frame option Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com> --- Text-Grab-Package/Package.appxmanifest | 23 +++++- Text-Grab/App.xaml.cs | 29 +++++++ Text-Grab/Utilities/ContextMenuUtilities.cs | 90 ++++++++++++++------- Text-Grab/Views/GrabFrame.xaml.cs | 12 +++ 4 files changed, 124 insertions(+), 30 deletions(-) diff --git a/Text-Grab-Package/Package.appxmanifest b/Text-Grab-Package/Package.appxmanifest index 06b11690..2589e52c 100644 --- a/Text-Grab-Package/Package.appxmanifest +++ b/Text-Grab-Package/Package.appxmanifest @@ -3,11 +3,13 @@ + IgnorableNamespaces="uap uap2 uap3 rescap com systemai desktop"> + + + + + .png + .jpg + .jpeg + .bmp + .gif + .tiff + .tif + + + Grab text with Text Grab + Open in Grab Frame + + + + HandleStartupArgs(string[] args) string currentArgument = args[0]; bool isQuiet = false; + bool openInGrabFrame = false; foreach (string arg in args) + { if (arg == "--windowless") { isQuiet = true; _defaultSettings.FirstRun = false; _defaultSettings.Save(); } + else if (arg == "--grabframe") + { + openInGrabFrame = true; + } + } + + // Handle --grabframe flag: open the next argument (file path) in GrabFrame + if (openInGrabFrame) + { + // Find the file path argument (skip flags starting with --) + string? filePath = null; + foreach (string arg in args) + { + if (!arg.StartsWith("--") && File.Exists(arg)) + { + filePath = arg; + break; + } + } + + if (!string.IsNullOrEmpty(filePath)) + { + GrabFrame gf = new(filePath); + gf.Show(); + return true; + } + } if (currentArgument.Contains("ToastActivated")) { diff --git a/Text-Grab/Utilities/ContextMenuUtilities.cs b/Text-Grab/Utilities/ContextMenuUtilities.cs index 32c9e110..4088aab2 100644 --- a/Text-Grab/Utilities/ContextMenuUtilities.cs +++ b/Text-Grab/Utilities/ContextMenuUtilities.cs @@ -6,12 +6,14 @@ namespace Text_Grab.Utilities; /// /// Utility class for managing Windows context menu integration. -/// Adds "Grab text with Text Grab" option to the right-click context menu for image files. +/// Adds "Grab text with Text Grab" and "Open in Grab Frame" options to the right-click context menu for image files. /// internal static class ContextMenuUtilities { - private const string ContextMenuRegistryKeyName = "Text-Grab.GrabText"; - private const string ContextMenuDisplayText = "Grab text with Text Grab"; + private const string GrabTextRegistryKeyName = "Text-Grab.GrabText"; + private const string GrabTextDisplayText = "Grab text with Text Grab"; + private const string GrabFrameRegistryKeyName = "Text-Grab.OpenInGrabFrame"; + private const string GrabFrameDisplayText = "Open in Grab Frame"; /// /// Supported image file extensions for context menu integration. @@ -29,7 +31,7 @@ internal static class ContextMenuUtilities /// /// Adds Text Grab to the Windows context menu for image files. - /// This allows users to right-click on an image and select "Grab text with Text Grab". + /// This allows users to right-click on an image and select "Grab text with Text Grab" or "Open in Grab Frame". /// /// When the method returns false, contains an error message describing the failure. /// True if registration was successful, false otherwise. @@ -48,7 +50,8 @@ public static bool AddToContextMenu(out string? errorMessage) { foreach (string extension in ImageExtensions) { - RegisterContextMenuForExtension(extension, executablePath); + RegisterGrabTextContextMenu(extension, executablePath); + RegisterGrabFrameContextMenu(extension, executablePath); } return true; } @@ -78,7 +81,8 @@ public static bool RemoveFromContextMenu(out string? errorMessage) { foreach (string extension in ImageExtensions) { - UnregisterContextMenuForExtension(extension); + UnregisterContextMenuForExtension(extension, GrabTextRegistryKeyName); + UnregisterContextMenuForExtension(extension, GrabFrameRegistryKeyName); } return true; } @@ -107,7 +111,7 @@ public static bool IsRegisteredInContextMenu() // Check if at least one extension has the context menu registered foreach (string extension in ImageExtensions) { - string keyPath = GetShellKeyPath(extension); + string keyPath = GetShellKeyPath(extension, GrabTextRegistryKeyName); using RegistryKey? key = Registry.CurrentUser.OpenSubKey(keyPath); if (key is not null) return true; @@ -122,19 +126,13 @@ public static bool IsRegisteredInContextMenu() } /// - /// Registers the context menu entry for a specific file extension. - /// Uses the Shell registration approach under HKEY_CURRENT_USER for per-user installation. + /// Registers the "Grab text with Text Grab" context menu entry for a specific file extension. /// - /// The file extension (e.g., ".png") - /// The path to the Text Grab executable - private static void RegisterContextMenuForExtension(string extension, string executablePath) + private static void RegisterGrabTextContextMenu(string extension, string executablePath) { - // Register under HKEY_CURRENT_USER\Software\Classes\ - // This approach works for per-user installation without requiring admin rights - string shellKeyPath = GetShellKeyPath(extension); + string shellKeyPath = GetShellKeyPath(extension, GrabTextRegistryKeyName); string commandKeyPath = $@"{shellKeyPath}\command"; - // Create the shell key with display name using (RegistryKey? shellKey = Registry.CurrentUser.CreateSubKey(shellKeyPath)) { if (shellKey is null) @@ -143,11 +141,10 @@ private static void RegisterContextMenuForExtension(string extension, string exe throw new InvalidOperationException($"Could not create registry key for {extension}"); } - shellKey.SetValue(string.Empty, ContextMenuDisplayText); + shellKey.SetValue(string.Empty, GrabTextDisplayText); shellKey.SetValue("Icon", $"\"{executablePath}\""); } - // Create the command key with the executable path using (RegistryKey? commandKey = Registry.CurrentUser.CreateSubKey(commandKeyPath)) { if (commandKey is null) @@ -162,34 +159,69 @@ private static void RegisterContextMenuForExtension(string extension, string exe } /// - /// Removes the context menu entry for a specific file extension. + /// Registers the "Open in Grab Frame" context menu entry for a specific file extension. /// - /// The file extension (e.g., ".png") - private static void UnregisterContextMenuForExtension(string extension) + private static void RegisterGrabFrameContextMenu(string extension, string executablePath) { - string shellKeyPath = GetShellKeyPath(extension); + string shellKeyPath = GetShellKeyPath(extension, GrabFrameRegistryKeyName); + string commandKeyPath = $@"{shellKeyPath}\command"; + + using (RegistryKey? shellKey = Registry.CurrentUser.CreateSubKey(shellKeyPath)) + { + if (shellKey is null) + { + Debug.WriteLine($"Failed to create registry key: {shellKeyPath}"); + throw new InvalidOperationException($"Could not create registry key for {extension}"); + } + + shellKey.SetValue(string.Empty, GrabFrameDisplayText); + shellKey.SetValue("Icon", $"\"{executablePath}\""); + } + + using (RegistryKey? commandKey = Registry.CurrentUser.CreateSubKey(commandKeyPath)) + { + if (commandKey is null) + { + Debug.WriteLine($"Failed to create registry key: {commandKeyPath}"); + throw new InvalidOperationException($"Could not create command registry key for {extension}"); + } + + // --grabframe flag opens the image in GrabFrame instead of EditTextWindow + commandKey.SetValue(string.Empty, $"\"{executablePath}\" --grabframe \"%1\""); + } + } + + /// + /// Removes a context menu entry for a specific file extension. + /// + private static void UnregisterContextMenuForExtension(string extension, string registryKeyName) + { + string shellKeyPath = GetShellKeyPath(extension, registryKeyName); try { - // Delete the entire shell key and its subkeys Registry.CurrentUser.DeleteSubKeyTree(shellKeyPath, throwOnMissingSubKey: false); } catch (Exception ex) { - // Log but don't throw - we want to continue trying to remove other extensions Debug.WriteLine($"Failed to unregister context menu for {extension}: {ex.Message}"); } } + /// + /// Gets the registry path for the shell context menu key for a given extension and registry key name. + /// + internal static string GetShellKeyPath(string extension, string registryKeyName = GrabTextRegistryKeyName) + { + return $@"Software\Classes\SystemFileAssociations\{extension}\shell\{registryKeyName}"; + } + /// /// Gets the registry path for the shell context menu key for a given extension. + /// Uses the default GrabText registry key name for backward compatibility with tests. /// - /// The file extension (e.g., ".png") - /// The registry key path internal static string GetShellKeyPath(string extension) { - // Using SystemFileAssociations allows the context menu to work regardless of - // which application is associated with the file type - return $@"Software\Classes\SystemFileAssociations\{extension}\shell\{ContextMenuRegistryKeyName}"; + return GetShellKeyPath(extension, GrabTextRegistryKeyName); } } diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index 9e3a06b7..16620c45 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -104,6 +104,18 @@ public GrabFrame(HistoryInfo historyInfo) historyItem = historyInfo; } + /// + /// Creates a GrabFrame and loads the specified image file. + /// + /// The path to the image file to load. + public GrabFrame(string imagePath) + { + StandardInitialize(); + + ShouldSaveOnClose = true; + Loaded += async (s, e) => await TryLoadImageFromPath(imagePath); + } + private async Task LoadContentFromHistory(HistoryInfo history) { FrameText = history.TextContent; From dc96e4eb20db6d82fe30b508eaf54d9581919575 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 18:40:33 +0000 Subject: [PATCH 008/109] Address code review feedback: improve argument parsing and path validation Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com> --- Text-Grab/App.xaml.cs | 11 +++++++++-- Text-Grab/Views/GrabFrame.xaml.cs | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Text-Grab/App.xaml.cs b/Text-Grab/App.xaml.cs index e067cb96..96f627e0 100644 --- a/Text-Grab/App.xaml.cs +++ b/Text-Grab/App.xaml.cs @@ -183,6 +183,8 @@ private static async Task CheckForOcringFolder(string currentArgument) return true; } + private static readonly HashSet KnownFlags = ["--windowless", "--grabframe"]; + private static async Task HandleStartupArgs(string[] args) { string currentArgument = args[0]; @@ -207,11 +209,11 @@ private static async Task HandleStartupArgs(string[] args) // Handle --grabframe flag: open the next argument (file path) in GrabFrame if (openInGrabFrame) { - // Find the file path argument (skip flags starting with --) + // Find the file path argument (skip known flags) string? filePath = null; foreach (string arg in args) { - if (!arg.StartsWith("--") && File.Exists(arg)) + if (!KnownFlags.Contains(arg) && File.Exists(arg)) { filePath = arg; break; @@ -224,6 +226,11 @@ private static async Task HandleStartupArgs(string[] args) gf.Show(); return true; } + else + { + Debug.WriteLine("--grabframe flag specified but no valid image file path provided"); + // Fall through to default launch behavior + } } if (currentArgument.Contains("ToastActivated")) diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index 16620c45..a4bba7da 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -113,6 +113,20 @@ public GrabFrame(string imagePath) StandardInitialize(); ShouldSaveOnClose = true; + + // Validate the path before loading + if (string.IsNullOrEmpty(imagePath)) + { + Debug.WriteLine("GrabFrame: Empty image path provided"); + return; + } + + if (!File.Exists(imagePath)) + { + Debug.WriteLine($"GrabFrame: Image file not found: {imagePath}"); + return; + } + Loaded += async (s, e) => await TryLoadImageFromPath(imagePath); } From 1dc80d355942391f82057a57092459026cd5d125 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 18:42:02 +0000 Subject: [PATCH 009/109] Improve path validation with GetFullPath and user error messages Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com> --- Text-Grab/App.xaml.cs | 18 +++++++++++++++--- Text-Grab/Views/GrabFrame.xaml.cs | 11 ++++++++--- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/Text-Grab/App.xaml.cs b/Text-Grab/App.xaml.cs index 96f627e0..706b4207 100644 --- a/Text-Grab/App.xaml.cs +++ b/Text-Grab/App.xaml.cs @@ -213,10 +213,22 @@ private static async Task HandleStartupArgs(string[] args) string? filePath = null; foreach (string arg in args) { - if (!KnownFlags.Contains(arg) && File.Exists(arg)) + if (!KnownFlags.Contains(arg)) { - filePath = arg; - break; + // Convert to absolute path to handle relative paths correctly + try + { + string absolutePath = Path.GetFullPath(arg); + if (File.Exists(absolutePath)) + { + filePath = absolutePath; + break; + } + } + catch (Exception ex) + { + Debug.WriteLine($"Invalid path argument: {arg}, error: {ex.Message}"); + } } } diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index a4bba7da..9bc9e02a 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -118,16 +118,21 @@ public GrabFrame(string imagePath) if (string.IsNullOrEmpty(imagePath)) { Debug.WriteLine("GrabFrame: Empty image path provided"); + Loaded += (s, e) => MessageBox.Show("No image file path was provided.", "Text Grab", MessageBoxButton.OK, MessageBoxImage.Warning); return; } - if (!File.Exists(imagePath)) + // Convert to absolute path to handle relative paths correctly + string absolutePath = Path.GetFullPath(imagePath); + + if (!File.Exists(absolutePath)) { - Debug.WriteLine($"GrabFrame: Image file not found: {imagePath}"); + Debug.WriteLine($"GrabFrame: Image file not found: {absolutePath}"); + Loaded += (s, e) => MessageBox.Show($"Image file not found:\n{absolutePath}", "Text Grab", MessageBoxButton.OK, MessageBoxImage.Warning); return; } - Loaded += async (s, e) => await TryLoadImageFromPath(imagePath); + Loaded += async (s, e) => await TryLoadImageFromPath(absolutePath); } private async Task LoadContentFromHistory(HistoryInfo history) From d5cac6a6d151ca455534f4b87dcdd796a449531a Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 11 Feb 2026 23:25:29 -0600 Subject: [PATCH 010/109] Add 'Paste in Grab Frame' to NotifyIcon context menu Adds a new menu item for pasting clipboard images into a GrabFrame. Enables/disables the item based on clipboard content. Handles both image files and image data, saving to temp file for editing. Includes helper for image file detection and required using directives. --- Text-Grab/Controls/NotifyIconWindow.xaml | 11 ++- Text-Grab/Controls/NotifyIconWindow.xaml.cs | 89 +++++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/Text-Grab/Controls/NotifyIconWindow.xaml b/Text-Grab/Controls/NotifyIconWindow.xaml index e5e4ccca..468c4ebd 100644 --- a/Text-Grab/Controls/NotifyIconWindow.xaml +++ b/Text-Grab/Controls/NotifyIconWindow.xaml @@ -27,7 +27,7 @@ LeftClick="NotifyIcon_LeftClick" TooltipText="Text Grab"> - + + + + + + ().FirstOrDefault(IsImageFile); + + if (imagePath is not null) + { + GrabFrame gf = new(imagePath); + gf.Show(); + gf.Activate(); + return; + } + } + + // Fall back to image content in clipboard (bitmap, base64, etc.) + (bool success, ImageSource? clipboardImage) = ClipboardUtilities.TryGetImageFromClipboard(); + + if (!success || clipboardImage is null) + return; + + BitmapSource? bitmapSource = null; + + if (clipboardImage is System.Windows.Interop.InteropBitmap interopBitmap) + { + System.Drawing.Bitmap bmp = ImageMethods.InteropBitmapToBitmap(interopBitmap); + bitmapSource = ImageMethods.BitmapToImageSource(bmp); + bmp.Dispose(); + } + else if (clipboardImage is BitmapSource source) + { + bitmapSource = source; + } + + if (bitmapSource is null) + return; + + string tempPath = Path.Combine(Path.GetTempPath(), $"TextGrab_Clipboard_{Guid.NewGuid()}.png"); + + using (FileStream fileStream = new(tempPath, FileMode.Create)) + { + PngBitmapEncoder encoder = new(); + encoder.Frames.Add(BitmapFrame.Create(bitmapSource)); + encoder.Save(fileStream); + } + + GrabFrame grabFrame = new(tempPath); + grabFrame.Show(); + grabFrame.Activate(); + } + + private void ContextMenu_Opened(object sender, RoutedEventArgs e) + { + bool hasClipboardImage = false; + + try + { + if (Clipboard.ContainsFileDropList()) + { + StringCollection files = Clipboard.GetFileDropList(); + hasClipboardImage = files.Cast().Any(IsImageFile); + } + + if (!hasClipboardImage) + hasClipboardImage = Clipboard.ContainsImage() || ClipboardUtilities.TryGetImageFromClipboard().Item1; + } + catch + { + hasClipboardImage = false; + } + + OpenClipboardImageGrabFrame.IsEnabled = hasClipboardImage; + } + + private static bool IsImageFile(string path) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + return false; + + string extension = Path.GetExtension(path).ToLowerInvariant(); + return extension is ".png" or ".jpg" or ".jpeg" or ".bmp" or ".gif" or ".tiff" or ".tif" or ".webp" or ".ico"; + } } From e273780d5d9e95fc0e47fa5aed98d4a934f0666a Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Thu, 12 Feb 2026 00:06:34 -0600 Subject: [PATCH 011/109] Add "Open With" & Share Target support for image files - Added "Open With" integration for image files (unpackaged/Win32): users can register/unregister Text Grab as an image handler via General Settings, enabling direct opening of images in Grab Frame from File Explorer. - Implemented registry logic for file association registration and removal, including shell notification. - Added a new user setting to persist "Open With" registration state. - Refactored image file extension checks into IoUtilities for consistency. - Updated context menu and clipboard logic to use new image file checks. - Added Share Target support for packaged (MSIX) apps, allowing Text Grab to receive images, text, URIs, and bitmaps from other apps. - Handled share target activations on app startup. - Updated General Settings UI with new toggle and explanatory text. - Improved error handling and performed minor code cleanups. --- Text-Grab-Package/Package.appxmanifest | 54 ++++--- Text-Grab/App.xaml.cs | 10 +- Text-Grab/Controls/NotifyIconWindow.xaml.cs | 13 +- Text-Grab/NativeMethods.cs | 5 +- Text-Grab/Pages/GeneralSettings.xaml | 18 +++ Text-Grab/Pages/GeneralSettings.xaml.cs | 25 ++- Text-Grab/Properties/Settings.Designer.cs | 12 ++ Text-Grab/Properties/Settings.settings | 5 +- Text-Grab/Utilities/ImplementAppOptions.cs | 108 ++++++++++++- Text-Grab/Utilities/IoUtilities.cs | 18 ++- Text-Grab/Utilities/ShareTargetUtilities.cs | 159 ++++++++++++++++++++ 11 files changed, 388 insertions(+), 39 deletions(-) create mode 100644 Text-Grab/Utilities/ShareTargetUtilities.cs diff --git a/Text-Grab-Package/Package.appxmanifest b/Text-Grab-Package/Package.appxmanifest index 2589e52c..0e2578b7 100644 --- a/Text-Grab-Package/Package.appxmanifest +++ b/Text-Grab-Package/Package.appxmanifest @@ -61,24 +61,42 @@ DisplayName="Text Grab" /> - - - - - .png - .jpg - .jpeg - .bmp - .gif - .tiff - .tif - - - Grab text with Text Grab - Open in Grab Frame - - - + + + + + .png + .jpg + .jpeg + .bmp + .gif + .tiff + .tif + + + Grab text with Text Grab + Open in Grab Frame + + + + + + + + + .png + .jpg + .jpeg + .bmp + .gif + .tiff + .tif + + Text + URI + Bitmap + + diff --git a/Text-Grab/App.xaml.cs b/Text-Grab/App.xaml.cs index 706b4207..bfbd56b8 100644 --- a/Text-Grab/App.xaml.cs +++ b/Text-Grab/App.xaml.cs @@ -310,7 +310,6 @@ private static async Task TryToOpenFile(string possiblePath, bool isQuiet) if (!File.Exists(possiblePath)) return false; - if (isQuiet) { (string pathContent, _) = await IoUtilities.GetContentFromPath(possiblePath); @@ -319,6 +318,12 @@ private static async Task TryToOpenFile(string possiblePath, bool isQuiet) false, false); } + else if (IoUtilities.IsImageFile(possiblePath)) + { + GrabFrame gf = new(possiblePath); + gf.Show(); + gf.Activate(); + } else { EditTextWindow manipulateTextWindow = new(); @@ -351,6 +356,9 @@ private async void appStartup(object sender, StartupEventArgs e) handledArgument = HandleNotifyIcon(); + if (!handledArgument) + handledArgument = await ShareTargetUtilities.HandleShareTargetActivationAsync(); + if (!handledArgument && e.Args.Length > 0) handledArgument = await HandleStartupArgs(e.Args); diff --git a/Text-Grab/Controls/NotifyIconWindow.xaml.cs b/Text-Grab/Controls/NotifyIconWindow.xaml.cs index 5cab7ed3..5e84088b 100644 --- a/Text-Grab/Controls/NotifyIconWindow.xaml.cs +++ b/Text-Grab/Controls/NotifyIconWindow.xaml.cs @@ -132,7 +132,7 @@ private void OpenClipboardImageGrabFrame_Click(object sender, RoutedEventArgs e) if (Clipboard.ContainsFileDropList()) { StringCollection files = Clipboard.GetFileDropList(); - string? imagePath = files.Cast().FirstOrDefault(IsImageFile); + string? imagePath = files.Cast().FirstOrDefault(IoUtilities.IsImageFile); if (imagePath is not null) { @@ -188,7 +188,7 @@ private void ContextMenu_Opened(object sender, RoutedEventArgs e) if (Clipboard.ContainsFileDropList()) { StringCollection files = Clipboard.GetFileDropList(); - hasClipboardImage = files.Cast().Any(IsImageFile); + hasClipboardImage = files.Cast().Any(IoUtilities.IsImageFile); } if (!hasClipboardImage) @@ -201,13 +201,4 @@ private void ContextMenu_Opened(object sender, RoutedEventArgs e) OpenClipboardImageGrabFrame.IsEnabled = hasClipboardImage; } - - private static bool IsImageFile(string path) - { - if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) - return false; - - string extension = Path.GetExtension(path).ToLowerInvariant(); - return extension is ".png" or ".jpg" or ".jpeg" or ".bmp" or ".gif" or ".tiff" or ".tif" or ".webp" or ".ico"; - } } diff --git a/Text-Grab/NativeMethods.cs b/Text-Grab/NativeMethods.cs index cb15c71c..ed8beccb 100644 --- a/Text-Grab/NativeMethods.cs +++ b/Text-Grab/NativeMethods.cs @@ -22,4 +22,7 @@ internal static partial class NativeMethods [LibraryImport("shcore.dll")] public static partial void GetScaleFactorForMonitor(IntPtr hMon, out uint pScale); -} \ No newline at end of file + + [LibraryImport("shell32.dll")] + public static partial void SHChangeNotify(int wEventId, uint uFlags, IntPtr dwItem1, IntPtr dwItem2); +} diff --git a/Text-Grab/Pages/GeneralSettings.xaml b/Text-Grab/Pages/GeneralSettings.xaml index d997b79d..dadfb836 100644 --- a/Text-Grab/Pages/GeneralSettings.xaml +++ b/Text-Grab/Pages/GeneralSettings.xaml @@ -243,6 +243,24 @@ Right-click on PNG, JPG, BMP, GIF, or TIFF files to quickly grab text. + + + + + Register Text Grab as an "Open with" app for image files + + + + Opens images directly in Grab Frame when using "Open with" from File Explorer. + + False + + False + - \ No newline at end of file + diff --git a/Text-Grab/Utilities/ImplementAppOptions.cs b/Text-Grab/Utilities/ImplementAppOptions.cs index ea0aa8d4..50ec062e 100644 --- a/Text-Grab/Utilities/ImplementAppOptions.cs +++ b/Text-Grab/Utilities/ImplementAppOptions.cs @@ -1,5 +1,6 @@ using Microsoft.Win32; using System; +using System.Diagnostics; using System.Threading.Tasks; using Windows.ApplicationModel; @@ -7,6 +8,8 @@ namespace Text_Grab.Utilities; internal class ImplementAppOptions { + private static readonly string[] ImageExtensions = [".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff", ".tif", ".webp", ".ico"]; + public static async Task ImplementStartupOption(bool startupOnLogin) { if (startupOnLogin) @@ -24,11 +27,106 @@ public static void ImplementBackgroundOption(bool runInBackground) else { App app = (App)App.Current; - if (app.TextGrabIcon != null) + app.TextGrabIcon?.Close(); + app.TextGrabIcon = null; + } + } + + public static void RegisterAsImageOpenWithApp() + { + if (AppUtilities.IsPackaged()) + return; // Packaged apps use the appxmanifest for file associations + + string executablePath = FileUtilities.GetExePath(); + if (string.IsNullOrEmpty(executablePath)) + return; + + try + { + // Register the application in the App Paths registry + string appKey = @"SOFTWARE\Classes\Text-Grab.Image"; + using (RegistryKey? key = Registry.CurrentUser.CreateSubKey(appKey)) + { + if (key is null) + return; + + key.SetValue("", "Text Grab - Image OCR"); + key.SetValue("FriendlyTypeName", "Text Grab Image"); + + using RegistryKey? shellKey = key.CreateSubKey(@"shell\open\command"); + shellKey?.SetValue("", $"\"{executablePath}\" \"%1\""); + + using RegistryKey? iconKey = key.CreateSubKey("DefaultIcon"); + iconKey?.SetValue("", $"\"{executablePath}\",0"); + } + + // Register Text Grab in OpenWithProgids for each image extension + foreach (string ext in ImageExtensions) + { + string extKey = $@"SOFTWARE\Classes\{ext}\OpenWithProgids"; + using RegistryKey? key = Registry.CurrentUser.CreateSubKey(extKey); + key?.SetValue("Text-Grab.Image", Array.Empty(), RegistryValueKind.None); + } + + // Register in the Applications key so Windows recognizes it + string appRegKey = @"SOFTWARE\Classes\Applications\Text-Grab.exe"; + using (RegistryKey? key = Registry.CurrentUser.CreateSubKey(appRegKey)) + { + if (key is null) + return; + + key.SetValue("FriendlyAppName", "Text Grab"); + + using RegistryKey? supportedTypes = key.CreateSubKey("SupportedTypes"); + if (supportedTypes is not null) + { + foreach (string ext in ImageExtensions) + supportedTypes.SetValue(ext, ""); + } + + using RegistryKey? shellKey = key.CreateSubKey(@"shell\open\command"); + shellKey?.SetValue("", $"\"{executablePath}\" \"%1\""); + } + + // Notify the shell of the change + NativeMethods.SHChangeNotify(0x08000000, 0x0000, IntPtr.Zero, IntPtr.Zero); + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to register file associations: {ex.Message}"); + } + } + + public static void UnregisterAsImageOpenWithApp() + { + if (AppUtilities.IsPackaged()) + return; + + try + { + // Remove the ProgId + Registry.CurrentUser.DeleteSubKeyTree(@"SOFTWARE\Classes\Text-Grab.Image", false); + + // Remove OpenWithProgids entries for each extension + foreach (string ext in ImageExtensions) { - app.TextGrabIcon.Close(); - app.TextGrabIcon = null; + string extKey = $@"SOFTWARE\Classes\{ext}\OpenWithProgids"; + using RegistryKey? key = Registry.CurrentUser.OpenSubKey(extKey, true); + if (key is not null) + { + try { key.DeleteValue("Text-Grab.Image", false); } + catch (Exception) { } + } } + + // Remove the Applications key + Registry.CurrentUser.DeleteSubKeyTree(@"SOFTWARE\Classes\Applications\Text-Grab.exe", false); + + NativeMethods.SHChangeNotify(0x08000000, 0x0000, IntPtr.Zero, IntPtr.Zero); + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to unregister file associations: {ex.Message}"); } } @@ -60,9 +158,9 @@ private static async Task SetForStartup() } else { - string path = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; + string path = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; string executablePath = FileUtilities.GetExePath(); - + RegistryKey? key = Registry.CurrentUser.OpenSubKey(path, true); if (key is not null && !string.IsNullOrEmpty(executablePath)) { diff --git a/Text-Grab/Utilities/IoUtilities.cs b/Text-Grab/Utilities/IoUtilities.cs index b57eab38..5d0a11ec 100644 --- a/Text-Grab/Utilities/IoUtilities.cs +++ b/Text-Grab/Utilities/IoUtilities.cs @@ -9,7 +9,23 @@ namespace Text_Grab.Utilities; public class IoUtilities { - public static readonly List ImageExtensions = [".png", ".bmp", ".jpg", ".jpeg", ".tiff", ".gif"]; + public static readonly List ImageExtensions = [".png", ".bmp", ".jpg", ".jpeg", ".tiff", ".gif", ".tif", ".webp", ".ico"]; + + public static bool IsImageFile(string path) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + return false; + + return IsImageFileExtension(Path.GetExtension(path)); + } + + public static bool IsImageFileExtension(string extension) + { + if (string.IsNullOrWhiteSpace(extension)) + return false; + + return ImageExtensions.Contains(extension.ToLowerInvariant()); + } public static async Task<(string TextContent, OpenContentKind SourceKindOfContent)> GetContentFromPath(string pathOfFileToOpen, bool isMultipleFiles = false, ILanguage? language = null) { diff --git a/Text-Grab/Utilities/ShareTargetUtilities.cs b/Text-Grab/Utilities/ShareTargetUtilities.cs new file mode 100644 index 00000000..75e66070 --- /dev/null +++ b/Text-Grab/Utilities/ShareTargetUtilities.cs @@ -0,0 +1,159 @@ +using Microsoft.Windows.AppLifecycle; +using System; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; +using System.Windows; +using Text_Grab.Views; +using Windows.ApplicationModel.Activation; +using Windows.ApplicationModel.DataTransfer; +using Windows.ApplicationModel.DataTransfer.ShareTarget; +using Windows.Storage; + +namespace Text_Grab.Utilities; + +public static class ShareTargetUtilities +{ + public static bool IsShareTargetActivation() + { + try + { + AppActivationArguments args = AppInstance.GetCurrent().GetActivatedEventArgs(); + return args.Kind == ExtendedActivationKind.ShareTarget; + } + catch (Exception ex) + { + Debug.WriteLine($"Error checking share target activation: {ex.Message}"); + return false; + } + } + + public static async Task HandleShareTargetActivationAsync() + { + try + { + AppActivationArguments args = AppInstance.GetCurrent().GetActivatedEventArgs(); + + if (args.Kind != ExtendedActivationKind.ShareTarget) + return false; + + if (args.Data is not ShareTargetActivatedEventArgs shareArgs) + return false; + + ShareOperation shareOperation = shareArgs.ShareOperation; + DataPackageView data = shareOperation.Data; + + bool handled = false; + + if (data.Contains(StandardDataFormats.StorageItems)) + { + handled = await HandleSharedStorageItemsAsync(data); + } + else if (data.Contains(StandardDataFormats.Bitmap)) + { + handled = await HandleSharedBitmapAsync(data); + } + else if (data.Contains(StandardDataFormats.Text)) + { + handled = await HandleSharedTextAsync(data); + } + else if (data.Contains(StandardDataFormats.Uri)) + { + handled = await HandleSharedUriAsync(data); + } + + shareOperation.ReportCompleted(); + return handled; + } + catch (Exception ex) + { + Debug.WriteLine($"Error handling share target activation: {ex.Message}"); + return false; + } + } + + private static async Task HandleSharedStorageItemsAsync(DataPackageView data) + { + var items = await data.GetStorageItemsAsync(); + + foreach (IStorageItem item in items) + { + if (item is StorageFile file && IoUtilities.IsImageFileExtension(Path.GetExtension(file.Path))) + { + GrabFrame gf = new(file.Path); + gf.Show(); + gf.Activate(); + return true; + } + } + + // If non-image files were shared, try to read as text + foreach (IStorageItem item in items) + { + if (item is StorageFile file) + { + try + { + string text = await FileIO.ReadTextAsync(file); + EditTextWindow etw = new(); + etw.AddThisText(text); + etw.Show(); + etw.Activate(); + return true; + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to read shared file as text: {ex.Message}"); + } + } + } + + return false; + } + + private static async Task HandleSharedBitmapAsync(DataPackageView data) + { + var bitmapRef = await data.GetBitmapAsync(); + using var stream = await bitmapRef.OpenReadAsync(); + + string tempPath = Path.Combine(Path.GetTempPath(), $"TextGrab_Share_{Guid.NewGuid():N}.png"); + + using (var fileStream = File.Create(tempPath)) + { + var inputStream = stream.GetInputStreamAt(0); + using var reader = new Windows.Storage.Streams.DataReader(inputStream); + ulong size = stream.Size; + await reader.LoadAsync((uint)size); + byte[] buffer = new byte[size]; + reader.ReadBytes(buffer); + await fileStream.WriteAsync(buffer); + } + + GrabFrame gf = new(tempPath); + gf.Show(); + gf.Activate(); + return true; + } + + private static async Task HandleSharedTextAsync(DataPackageView data) + { + string text = await data.GetTextAsync(); + + EditTextWindow etw = new(); + etw.AddThisText(text); + etw.Show(); + etw.Activate(); + return true; + } + + private static async Task HandleSharedUriAsync(DataPackageView data) + { + Uri uri = await data.GetUriAsync(); + + EditTextWindow etw = new(); + etw.AddThisText(uri.ToString()); + etw.Show(); + etw.Activate(); + return true; + } +} From 42d79cf7eb3ced4a5726e4cab1ffe2683b6d0821 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Thu, 12 Feb 2026 18:11:36 -0600 Subject: [PATCH 012/109] Start redraw timer after image drop in GrabFrame Added reDrawTimer.Start() to initiate UI updates when a new image is loaded via drag-and-drop in GrabFrame.xaml.cs. This ensures periodic actions or redraws occur for the newly loaded frame. --- Text-Grab/Views/GrabFrame.xaml.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index 9bc9e02a..d76bb84d 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -2173,6 +2173,8 @@ private async Task TryLoadImageFromPath(string path) FreezeToggleButton.IsChecked = true; FreezeGrabFrame(); FreezeToggleButton.Visibility = Visibility.Collapsed; + + reDrawTimer.Start(); } catch (Exception) { From 83cafd34dc81f6e794773f60f6bcab6cab7aa89d Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Mon, 23 Feb 2026 23:40:52 -0600 Subject: [PATCH 013/109] Improve handling of loaded vs. captured images in GrabFrame Add hasLoadedImageSource to track if an image is loaded from file, clipboard, or drag-and-drop versus captured from the screen. Update undo/redo and OCR logic to use this flag, preventing unnecessary screen captures and ensuring correct behavior when working with loaded images. Refactor ResetGrabFrame and UnfreezeGrabFrame to respect the image source, and add SetRefreshOrOcrFrameBtnVis to manage OCR/refresh button visibility. Improves reliability and user experience when switching between image sources. --- Text-Grab/Views/GrabFrame.xaml.cs | 54 ++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index d76bb84d..60953e89 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -54,6 +54,7 @@ public partial class GrabFrame : Window private TextBox? destinationTextBox; private ImageSource? frameContentImageSource; private HistoryInfo? historyItem; + private bool hasLoadedImageSource = false; private bool IsDragOver = false; private bool isDrawing = false; private bool isLanguageBoxLoaded = false; @@ -113,7 +114,7 @@ public GrabFrame(string imagePath) StandardInitialize(); ShouldSaveOnClose = true; - + // Validate the path before loading if (string.IsNullOrEmpty(imagePath)) { @@ -154,6 +155,7 @@ private async Task LoadContentFromHistory(HistoryInfo history) } frameContentImageSource = ImageMethods.BitmapToImageSource(bgBitmap); + hasLoadedImageSource = true; GrabFrameImage.Source = frameContentImageSource; FreezeGrabFrame(); @@ -1739,6 +1741,7 @@ private async void PasteExecuted(object sender, ExecutedRoutedEventArgs? e = nul frameContentImageSource = clipboardImage; } + hasLoadedImageSource = true; FreezeToggleButton.IsChecked = true; FreezeGrabFrame(); FreezeToggleButton.Visibility = Visibility.Collapsed; @@ -1949,18 +1952,38 @@ private async void RefreshBTN_Click(object? sender = null, RoutedEventArgs? e = UndoRedo.InsertUndoRedoOperation(UndoRedoOperation.RemoveWordBorder, new GrabFrameOperationArgs() -{ - RemovingWordBorders = [.. wordBorders], - WordBorders = wordBorders, - GrabFrameCanvas = RectanglesCanvas -}); + { + RemovingWordBorders = [.. wordBorders], + WordBorders = wordBorders, + GrabFrameCanvas = RectanglesCanvas + }); - ResetGrabFrame(); + if (hasLoadedImageSource) + { + // For loaded images, clear OCR results and re-run OCR on the same image. + // Zoom must be reset because the screen-capture-based OCR pipeline + // calculates word border positions assuming no zoom transform. + MainZoomBorder.Reset(); + RectanglesCanvas.RenderTransform = Transform.Identity; + IsOcrValid = false; + ocrResultOfWindow = null; + RectanglesCanvas.Children.Clear(); + wordBorders.Clear(); + MatchesTXTBLK.Text = "- Matches"; + UpdateFrameText(); + + // Allow WPF to repaint the unzoomed view before screen-capture OCR + await Task.Delay(200); + } + else + { + ResetGrabFrame(); - await Task.Delay(200); + await Task.Delay(200); - frameContentImageSource = ImageMethods.GetWindowBoundsImage(this); - GrabFrameImage.Source = frameContentImageSource; + frameContentImageSource = ImageMethods.GetWindowBoundsImage(this); + GrabFrameImage.Source = frameContentImageSource; + } if (AutoOcrCheckBox.IsChecked is false) FreezeGrabFrame(); @@ -2055,9 +2078,13 @@ private void ResetGrabFrame() SetRefreshOrOcrFrameBtnVis(); MainZoomBorder.Reset(); + RectanglesCanvas.RenderTransform = Transform.Identity; IsOcrValid = false; ocrResultOfWindow = null; - frameContentImageSource = null; + + if (!hasLoadedImageSource) + frameContentImageSource = null; + RectanglesCanvas.Children.Clear(); wordBorders.Clear(); MatchesTXTBLK.Text = "- Matches"; @@ -2109,6 +2136,7 @@ private void SetGrabFrameUserSettings() DefaultSettings.GrabFrameUpdateEtw = AlwaysUpdateEtwCheckBox.IsChecked; DefaultSettings.Save(); } + private void SetRefreshOrOcrFrameBtnVis() { if (AutoOcrCheckBox.IsChecked is false) @@ -2170,6 +2198,7 @@ private async Task TryLoadImageFromPath(string path) ImageMethods.RotateImage(droppedImage, rotateFlipType); droppedImage.EndInit(); frameContentImageSource = droppedImage; + hasLoadedImageSource = true; FreezeToggleButton.IsChecked = true; FreezeGrabFrame(); FreezeToggleButton.Visibility = Visibility.Collapsed; @@ -2178,6 +2207,7 @@ private async Task TryLoadImageFromPath(string path) } catch (Exception) { + hasLoadedImageSource = false; UnfreezeGrabFrame(); MessageBox.Show("Not an image"); } @@ -2306,6 +2336,7 @@ private void UndoExecuted(object sender, ExecutedRoutedEventArgs e) private void UnfreezeGrabFrame() { reDrawTimer.Stop(); + hasLoadedImageSource = false; ResetGrabFrame(); Topmost = true; GrabFrameImage.Opacity = 0; @@ -2499,7 +2530,6 @@ private void GrabTrimExecuted(object sender, ExecutedRoutedEventArgs e) Close(); } - private void ScrollBehaviorMenuItem_Click(object sender, RoutedEventArgs e) { if (sender is not MenuItem menuItem || !Enum.TryParse(menuItem.Tag.ToString(), out scrollBehavior)) From 7147cd245a48b9a93f3920e778d2f05a0bdf9ac4 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 25 Feb 2026 22:52:54 -0600 Subject: [PATCH 014/109] Code formatting --- Text-Grab/Views/GrabFrame.xaml.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index 60953e89..f9db5caa 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -1952,11 +1952,11 @@ private async void RefreshBTN_Click(object? sender = null, RoutedEventArgs? e = UndoRedo.InsertUndoRedoOperation(UndoRedoOperation.RemoveWordBorder, new GrabFrameOperationArgs() - { - RemovingWordBorders = [.. wordBorders], - WordBorders = wordBorders, - GrabFrameCanvas = RectanglesCanvas - }); +{ + RemovingWordBorders = [.. wordBorders], + WordBorders = wordBorders, + GrabFrameCanvas = RectanglesCanvas +}); if (hasLoadedImageSource) { From cc979facb066715c4f63d60a415e875a910dd87c Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 25 Feb 2026 23:27:33 -0600 Subject: [PATCH 015/109] Add date/time math support to calculator and tests Implemented parsing and evaluation of date/time math expressions (e.g., "March 10, 2026 + 10 days", "today + 5 days") in the calculator logic. Supported a wide range of time units, fractional values, ordinal suffixes, and special keywords. Integrated this feature into the main calculation flow without breaking existing math, variable, or function support. Added a new CalculationService.DateTimeMath.cs for all related logic and comprehensive unit tests to ensure correctness and compatibility. --- Tests/CalculatorTests.cs | 423 ++++++++++++++++++ .../CalculationService.DateTimeMath.cs | 222 +++++++++ Text-Grab/Services/CalculationService.cs | 10 +- 3 files changed, 652 insertions(+), 3 deletions(-) create mode 100644 Text-Grab/Services/CalculationService.DateTimeMath.cs diff --git a/Tests/CalculatorTests.cs b/Tests/CalculatorTests.cs index 829f88c0..09c52750 100644 --- a/Tests/CalculatorTests.cs +++ b/Tests/CalculatorTests.cs @@ -2349,4 +2349,427 @@ public async Task Percentage_WithSum_WorksCorrectly() } #endregion Percentage Tests + + #region DateTime Math Tests + + [Theory] + [InlineData("March 10, 2026 + 10 days", "3/20/2026")] + [InlineData("January 1, 2026 + 30 days", "1/31/2026")] + [InlineData("December 25, 2026 + 7 days", "1/1/2027")] + [InlineData("February 28, 2026 + 1 day", "3/1/2026")] + public async Task DateTimeMath_AddDays_ReturnsCorrectDate(string input, string expected) + { + CalculationService service = new(); + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Theory] + [InlineData("January 1, 2026 + 2 weeks", "1/15/2026")] + [InlineData("March 1, 2026 + 1 week", "3/8/2026")] + [InlineData("March 14, 2026 - 2 weeks", "2/28/2026")] + public async Task DateTimeMath_AddWeeks_ReturnsCorrectDate(string input, string expected) + { + CalculationService service = new(); + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Theory] + [InlineData("January 15, 2026 + 3 months", "4/15/2026")] + [InlineData("October 31, 2026 + 1 month", "11/30/2026")] + [InlineData("March 31, 2026 + 1 month", "4/30/2026")] + public async Task DateTimeMath_AddMonths_ReturnsCorrectDate(string input, string expected) + { + CalculationService service = new(); + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Theory] + [InlineData("January 1, 2020 + 5 years", "1/1/2025")] + [InlineData("February 29, 2024 + 1 year", "2/28/2025")] + [InlineData("June 15, 2026 + 10 years", "6/15/2036")] + public async Task DateTimeMath_AddYears_ReturnsCorrectDate(string input, string expected) + { + CalculationService service = new(); + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Theory] + [InlineData("January 1, 2000 + 2 decades", "1/1/2020")] + [InlineData("June 15, 2010 + 1 decade", "6/15/2020")] + [InlineData("March 1, 2026 + 3 decades", "3/1/2056")] + public async Task DateTimeMath_AddDecades_ReturnsCorrectDate(string input, string expected) + { + CalculationService service = new(); + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Theory] + [InlineData("March 20, 2026 - 10 days", "3/10/2026")] + [InlineData("January 5, 2026 - 10 days", "12/26/2025")] + [InlineData("March 1, 2026 - 1 month", "2/1/2026")] + [InlineData("January 1, 2026 - 1 year", "1/1/2025")] + public async Task DateTimeMath_Subtraction_ReturnsCorrectDate(string input, string expected) + { + CalculationService service = new(); + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_WithOrdinalSuffix_ParsesCorrectly() + { + CalculationService service = new(); + string input = "March 10th, 2026 + 10 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("3/20/2026", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Theory] + [InlineData("January 1st, 2026 + 5 days", "1/6/2026")] + [InlineData("February 2nd, 2026 + 5 days", "2/7/2026")] + [InlineData("March 3rd, 2026 + 5 days", "3/8/2026")] + [InlineData("April 4th, 2026 + 5 days", "4/9/2026")] + [InlineData("May 21st, 2026 + 1 day", "5/22/2026")] + public async Task DateTimeMath_VariousOrdinalSuffixes_ParseCorrectly(string input, string expected) + { + CalculationService service = new(); + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_NumericDateFormat_ParsesCorrectly() + { + CalculationService service = new(); + string input = "3/10/2026 + 10 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("3/20/2026", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_TwoDigitYear_ParsesCorrectly() + { + CalculationService service = new(); + string input = "3/10/26 + 10 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("3/20/2026", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_WithTimePM_ShowsTimeInResult() + { + CalculationService service = new(); + string input = "1/1/2026 2:00pm + 5 hours"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Contains("1/1/2026", result.Output); + Assert.Contains("7:00pm", result.Output.ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_With24HrTime_ShowsTimeInResult() + { + CalculationService service = new(); + string input = "1/1/2026 14:30 + 2 hours"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Contains("1/1/2026", result.Output); + Assert.Contains("4:30pm", result.Output.ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_MinutesAddition_CrossesDayBoundary() + { + CalculationService service = new(); + string input = "2/25/2026 11:02pm + 800 mins"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Contains("2/26/2026", result.Output); + Assert.Contains("12:22pm", result.Output.ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_HoursAddition_CrossesDayBoundary() + { + CalculationService service = new(); + string input = "1/1/2026 10:00pm + 5 hours"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Contains("1/2/2026", result.Output); + Assert.Contains("3:00am", result.Output.ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_MultipleOperations_WorkCorrectly() + { + CalculationService service = new(); + string input = "January 1, 2026 + 2 weeks + 3 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("1/18/2026", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_MixedAddSubtract_WorkCorrectly() + { + CalculationService service = new(); + string input = "March 1, 2026 + 1 month - 5 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("3/27/2026", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_MixedUnits_YearsAndMonths() + { + CalculationService service = new(); + string input = "January 1, 2026 + 1 year + 6 months"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("7/1/2027", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_TodayKeyword_Works() + { + CalculationService service = new(); + string input = "today + 5 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expected = DateTime.Today.AddDays(5).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_TomorrowKeyword_Works() + { + CalculationService service = new(); + string input = "tomorrow + 1 week"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expected = DateTime.Today.AddDays(8).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_YesterdayKeyword_Works() + { + CalculationService service = new(); + string input = "yesterday + 2 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expected = DateTime.Today.AddDays(1).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_FractionalDays_AssumesNoon() + { + CalculationService service = new(); + // March 10 noon + 1.5 days = March 10 12:00 + 36 hours = March 12 00:00 (midnight) + // Since result is midnight, only date is shown + string input = "March 10, 2026 + 1.5 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("3/12/2026", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_FractionalDays_ShowsTimeWhenNonMidnight() + { + CalculationService service = new(); + // March 10 noon + 1.3 days = March 10 12:00 + 31.2 hours = March 11 19:12 + string input = "March 10, 2026 + 1.3 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Contains("3/11/2026", result.Output); + Assert.Contains("7:12pm", result.Output.ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_MinutesWithoutTime_ShowsTime() + { + CalculationService service = new(); + // No time specified, so base is midnight; adding minutes produces a time result + string input = "January 1, 2026 + 90 minutes"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Contains("1/1/2026", result.Output); + Assert.Contains("1:30am", result.Output.ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_DoesNotBreakRegularMath() + { + CalculationService service = new(); + string input = "5 + 3"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("8", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_DoesNotBreakExistingFunctions() + { + CalculationService service = new(); + string input = "Sin(Pi/2)\nSqrt(16)\nAbs(-10)"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string[] lines = result.Output.Split('\n'); + Assert.Equal("1", lines[0]); + Assert.Equal("4", lines[1]); + Assert.Equal("10", lines[2]); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_DoesNotBreakVariableAssignment() + { + CalculationService service = new(); + string input = "x = 10\nx * 2"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string[] lines = result.Output.Split('\n'); + Assert.Contains("x = 10", lines[0]); + Assert.Equal("20", lines[1]); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_DoesNotBreakPercentages() + { + CalculationService service = new(); + string input = "100 * 15%"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("15", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_DoesNotBreakQuantityWords() + { + CalculationService service = new(); + string input = "5 million + 3 thousand"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("5003000", result.Output.Replace(",", "")); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_WithMixedExpressions_WorksCorrectly() + { + CalculationService service = new(); + string input = "March 10, 2026 + 10 days\n5 + 3\nJanuary 1, 2026 + 1 year"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string[] lines = result.Output.Split('\n'); + Assert.Equal(3, lines.Length); + Assert.Equal("3/20/2026", lines[0]); + Assert.Equal("8", lines[1]); + Assert.Equal("1/1/2027", lines[2]); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public void TryEvaluateDateTimeMath_EmptyInput_ReturnsFalse() + { + bool result = CalculationService.TryEvaluateDateTimeMath("", out _); + Assert.False(result); + } + + [Fact] + public void TryEvaluateDateTimeMath_NoTimeUnits_ReturnsFalse() + { + bool result = CalculationService.TryEvaluateDateTimeMath("5 + 3", out _); + Assert.False(result); + } + + [Fact] + public void TryEvaluateDateTimeMath_InvalidDate_ReturnsFalse() + { + bool result = CalculationService.TryEvaluateDateTimeMath("notadate + 5 days", out _); + Assert.False(result); + } + + [Fact] + public async Task DateTimeMath_DateOnly_DoesNotIncludeTime() + { + CalculationService service = new(); + string input = "March 10, 2026 + 10 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Should not contain am/pm since it's date-only + Assert.DoesNotContain("am", result.Output.ToLowerInvariant()); + Assert.DoesNotContain("pm", result.Output.ToLowerInvariant()); + } + + [Fact] + public async Task DateTimeMath_NoDateStartOperator_UsesToday() + { + CalculationService service = new(); + string input = "+ 7 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expected = DateTime.Today.AddDays(7).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Theory] + [InlineData("Jan 1, 2026 + 5 days", "1/6/2026")] + [InlineData("Feb 14, 2026 + 1 week", "2/21/2026")] + [InlineData("Dec 31, 2026 + 1 day", "1/1/2027")] + public async Task DateTimeMath_AbbreviatedMonths_ParseCorrectly(string input, string expected) + { + CalculationService service = new(); + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_RealWorldExample_ProjectDeadline() + { + CalculationService service = new(); + string input = "June 1st, 2026 + 6 months + 2 weeks"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + // June 1 + 6 months = Dec 1, + 2 weeks = Dec 15 + Assert.Equal("12/15/2026", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_RealWorldExample_MeetingTime() + { + CalculationService service = new(); + string input = "3/15/2026 9:00am + 90 mins"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Contains("3/15/2026", result.Output); + Assert.Contains("10:30am", result.Output.ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_RealWorldExample_FlightArrival() + { + CalculationService service = new(); + // Flight departs 11pm, arrives after 14 hours + string input = "7/4/2026 11:00pm + 14 hours"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Contains("7/5/2026", result.Output); + Assert.Contains("1:00pm", result.Output.ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + #endregion DateTime Math Tests } diff --git a/Text-Grab/Services/CalculationService.DateTimeMath.cs b/Text-Grab/Services/CalculationService.DateTimeMath.cs new file mode 100644 index 00000000..97e66224 --- /dev/null +++ b/Text-Grab/Services/CalculationService.DateTimeMath.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace Text_Grab.Services; + +public partial class CalculationService +{ + /// + /// Attempts to evaluate a line as a date/time math expression. + /// Supports expressions like "March 10th + 10 days", "2/25/26 11:02pm + 800 mins", etc. + /// Supported units: days, weeks, months, years, decades, hours, minutes. + /// + /// The input line to evaluate + /// The formatted date/time result if successful + /// True if the line was successfully evaluated as a date/time math expression + public static bool TryEvaluateDateTimeMath(string line, out string result) + { + result = string.Empty; + if (string.IsNullOrWhiteSpace(line)) + return false; + + // Find all arithmetic operations with time units + MatchCollection matches = DateTimeArithmeticPattern().Matches(line); + if (matches.Count == 0) + return false; + + // Everything before the first arithmetic match is the date part + string datePart = line[..matches[0].Index].Trim(); + + // Parse the base date + DateTime dateTime; + bool hasInputTime; + + if (string.IsNullOrEmpty(datePart)) + { + dateTime = DateTime.Today; + hasInputTime = false; + } + else if (!TryParseFlexibleDate(datePart, out dateTime, out hasInputTime)) + { + return false; + } + + // First pass: collect operations and detect characteristics + bool hasTimeUnits = false; + bool hasFractionalDayOrLarger = false; + List<(double Number, string Unit)> operations = []; + + foreach (Match match in matches) + { + string op = match.Groups["op"].Value; + string numberStr = match.Groups["number"].Value; + string unit = match.Groups["unit"].Value.ToLowerInvariant(); + + if (!double.TryParse(numberStr, NumberStyles.Any, CultureInfo.InvariantCulture, out double number)) + return false; + + if (op == "-") + number = -number; + + bool isTimeUnit = unit is "hour" or "hours" or "hr" or "hrs" + or "minute" or "minutes" or "min" or "mins"; + if (isTimeUnit) + hasTimeUnits = true; + else if (number % 1 != 0) + hasFractionalDayOrLarger = true; + + operations.Add((number, unit)); + } + + // If fractional day+ units and no explicit time, assume noon as starting time + if (hasFractionalDayOrLarger && !hasInputTime && dateTime.TimeOfDay == TimeSpan.Zero) + dateTime = dateTime.AddHours(12); + + // Second pass: apply all operations + foreach ((double number, string unit) in operations) + dateTime = ApplyDateTimeOffset(dateTime, number, unit); + + // Determine whether to include time in the output + bool showTime = hasInputTime || hasTimeUnits || + (hasFractionalDayOrLarger && dateTime.TimeOfDay != TimeSpan.Zero); + + result = FormatDateTimeResult(dateTime, showTime); + return true; + } + + /// + /// Applies a numeric offset with a time unit to a DateTime. + /// + private static DateTime ApplyDateTimeOffset(DateTime dateTime, double number, string unit) + { + return unit switch + { + "decade" or "decades" => AddFractionalYears(dateTime, number * 10), + "year" or "years" => AddFractionalYears(dateTime, number), + "month" or "months" => AddFractionalMonths(dateTime, number), + "week" or "weeks" => dateTime.AddDays(number * 7), + "day" or "days" => dateTime.AddDays(number), + "hour" or "hours" or "hr" or "hrs" => dateTime.AddHours(number), + "minute" or "minutes" or "min" or "mins" => dateTime.AddMinutes(number), + _ => dateTime + }; + } + + private static DateTime AddFractionalYears(DateTime dateTime, double years) + { + int wholeYears = (int)years; + double fraction = years - wholeYears; + + dateTime = dateTime.AddYears(wholeYears); + if (Math.Abs(fraction) > double.Epsilon) + dateTime = dateTime.AddDays(fraction * 365.25); + + return dateTime; + } + + private static DateTime AddFractionalMonths(DateTime dateTime, double months) + { + int wholeMonths = (int)months; + double fraction = months - wholeMonths; + + dateTime = dateTime.AddMonths(wholeMonths); + if (Math.Abs(fraction) > double.Epsilon) + dateTime = dateTime.AddDays(fraction * 30.44); + + return dateTime; + } + + /// + /// Attempts to parse a date string flexibly, supporting various formats + /// including named months with ordinal suffixes, numeric dates, and special keywords. + /// Uses the current system culture for parsing. + /// + private static bool TryParseFlexibleDate(string input, out DateTime dateTime, out bool hasTime) + { + dateTime = default; + hasTime = false; + if (string.IsNullOrWhiteSpace(input)) + return false; + + string cleaned = input.Trim(); + + // Handle special keywords + switch (cleaned.ToLowerInvariant()) + { + case "today": + dateTime = DateTime.Today; + return true; + case "now": + dateTime = DateTime.Now; + hasTime = true; + return true; + case "tomorrow": + dateTime = DateTime.Today.AddDays(1); + return true; + case "yesterday": + dateTime = DateTime.Today.AddDays(-1); + return true; + } + + // Remove ordinal suffixes (1st, 2nd, 3rd, 4th, etc.) + cleaned = OrdinalSuffixPattern().Replace(cleaned, "$1"); + + // Detect if input has a time component + hasTime = HasTimeComponent(input); + + // Try parsing with current culture + if (DateTime.TryParse(cleaned, CultureInfo.CurrentCulture, DateTimeStyles.AllowWhiteSpaces, out dateTime)) + return true; + + return false; + } + + /// + /// Checks if a string contains a time component (am/pm indicators or HH:mm patterns). + /// + private static bool HasTimeComponent(string input) + { + // Check for am/pm indicators (e.g., 10am, 10 am, 10a.m., 10pm) + if (AmPmPattern().IsMatch(input)) + return true; + + // Check for colon-separated time (e.g., 11:02, 14:30) + if (ColonTimePattern().IsMatch(input)) + return true; + + return false; + } + + /// + /// Formats a DateTime result for display. + /// Uses the current culture's short date format for dates. + /// When time is included, appends 12-hour time with lowercase am/pm. + /// + private static string FormatDateTimeResult(DateTime dateTime, bool includeTime) + { + CultureInfo culture = CultureInfo.CurrentCulture; + + if (includeTime) + { + string datePart = dateTime.ToString("d", culture); + string timePart = dateTime.ToString("h:mmtt", culture).ToLowerInvariant(); + return $"{datePart} {timePart}"; + } + + return dateTime.ToString("d", culture); + } + + [System.Text.RegularExpressions.GeneratedRegex(@"(?[+-])\s*(?\d+\.?\d*)\s*(?decades?|years?|months?|weeks?|days?|hours?|hrs?|hr|minutes?|mins?|min)\b", System.Text.RegularExpressions.RegexOptions.IgnoreCase)] + private static partial System.Text.RegularExpressions.Regex DateTimeArithmeticPattern(); + + [System.Text.RegularExpressions.GeneratedRegex(@"(\d+)(?:st|nd|rd|th)\b", System.Text.RegularExpressions.RegexOptions.IgnoreCase)] + private static partial System.Text.RegularExpressions.Regex OrdinalSuffixPattern(); + + [System.Text.RegularExpressions.GeneratedRegex(@"\d\s*[aApP]\.?[mM]\.?(?:\s|$|[^a-zA-Z])")] + private static partial System.Text.RegularExpressions.Regex AmPmPattern(); + + [System.Text.RegularExpressions.GeneratedRegex(@"\d{1,2}:\d{2}")] + private static partial System.Text.RegularExpressions.Regex ColonTimePattern(); +} diff --git a/Text-Grab/Services/CalculationService.cs b/Text-Grab/Services/CalculationService.cs index 342e28f2..76601016 100644 --- a/Text-Grab/Services/CalculationService.cs +++ b/Text-Grab/Services/CalculationService.cs @@ -61,11 +61,15 @@ public async Task EvaluateExpressionsAsync(string input) try { - if (IsParameterAssignment(trimmedLine)) + if (TryEvaluateDateTimeMath(trimmedLine, out string dateTimeResult)) + { + results.Add(dateTimeResult); + } + else if (IsParameterAssignment(trimmedLine)) { string resultLine = await HandleParameterAssignmentAsync(trimmedLine); results.Add(resultLine); - + // Extract variable name and add its value to output numbers int equalIndex = trimmedLine.IndexOf('='); string variableName = trimmedLine[..equalIndex].Trim(); @@ -86,7 +90,7 @@ public async Task EvaluateExpressionsAsync(string input) { string resultLine = await EvaluateStandardExpressionAsync(trimmedLine); results.Add(resultLine); - + // Try to parse the result as a number and add to output numbers // Remove formatting characters before parsing string cleanedResult = resultLine.Replace(",", "").Replace(" ", "").Trim(); From f9258d28c9f33619176390648487b41a85f072d4 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 25 Feb 2026 23:37:16 -0600 Subject: [PATCH 016/109] Add support for combined duration segments in date math Enhances date/time math parsing to allow combined duration segments (e.g., "today + 5 weeks 3 days 8 hours"), where multiple units can be chained after a single operator and inherit the previous operator. Introduces a new regex for parsing these segments and updates the evaluation logic accordingly. Adds comprehensive unit tests for various combined segment scenarios and real-world use cases. Existing explicit-operator and regular math expressions remain fully supported. Improves natural language flexibility for users. --- Tests/CalculatorTests.cs | 219 ++++++++++++++++++ .../CalculationService.DateTimeMath.cs | 37 ++- 2 files changed, 245 insertions(+), 11 deletions(-) diff --git a/Tests/CalculatorTests.cs b/Tests/CalculatorTests.cs index 09c52750..d0c89596 100644 --- a/Tests/CalculatorTests.cs +++ b/Tests/CalculatorTests.cs @@ -2772,4 +2772,223 @@ public async Task DateTimeMath_RealWorldExample_FlightArrival() } #endregion DateTime Math Tests + + #region Combined Duration Segment Tests + + [Fact] + public async Task DateTimeMath_CombinedSegments_WeeksDaysHours() + { + CalculationService service = new(); + // January 1, 2026 + 5 weeks 3 days 8 hours + // = Jan 1 + 35 days + 3 days + 8 hours = Feb 8 2026 8:00am + string input = "January 1, 2026 + 5 weeks 3 days 8 hours"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Contains("2/8/2026", result.Output); + Assert.Contains("8:00am", result.Output.ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_TodayKeyword() + { + CalculationService service = new(); + string input = "today + 5 weeks 3 days 8 hours"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + DateTime expected = DateTime.Today.AddDays(5 * 7 + 3).AddHours(8); + Assert.Contains(expected.ToString("d", CultureInfo.CurrentCulture), result.Output); + Assert.Contains("8:00am", result.Output.ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_YearsMonths() + { + CalculationService service = new(); + // January 1, 2026 + 1 year 6 months = July 1, 2027 + string input = "January 1, 2026 + 1 year 6 months"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("7/1/2027", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_WeeksDays() + { + CalculationService service = new(); + // March 10, 2026 + 2 weeks 3 days = March 10 + 14 + 3 = March 27 + string input = "March 10, 2026 + 2 weeks 3 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("3/27/2026", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_HoursMinutes() + { + CalculationService service = new(); + // 1/1/2026 9:00am + 2 hours 30 mins = 11:30am + string input = "1/1/2026 9:00am + 2 hours 30 mins"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Contains("1/1/2026", result.Output); + Assert.Contains("11:30am", result.Output.ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_ThreeUnits() + { + CalculationService service = new(); + // January 1, 2026 + 1 month 2 weeks 3 days + // = Feb 1 + 14 days + 3 days = Feb 18 + string input = "January 1, 2026 + 1 month 2 weeks 3 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("2/18/2026", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_FourUnits() + { + CalculationService service = new(); + // January 1, 2026 + 1 year 2 months 1 week 3 days + // = Jan 1 2027 + 2 months = Mar 1 2027 + 7 days = Mar 8 + 3 days = Mar 11 + string input = "January 1, 2026 + 1 year 2 months 1 week 3 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("3/11/2027", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_WithOperatorChange() + { + CalculationService service = new(); + // March 1, 2026 + 1 month 5 days - 2 hours + // = April 1 + 5 days = April 6, then - 2 hours crosses to April 5 10:00pm + string input = "March 1, 2026 + 1 month 5 days - 2 hours"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Contains("4/5/2026", result.Output); + Assert.Contains("10:00pm", result.Output.ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_SubtractMultiple() + { + CalculationService service = new(); + // April 30, 2026 - 1 month 10 days + // = March 30 - 10 days = March 20 + string input = "April 30, 2026 - 1 month 10 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("3/20/2026", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_OperatorInheritance() + { + CalculationService service = new(); + // Implicit segments inherit the most recent operator. + // June 15, 2026 + 2 weeks 3 days - 1 week 2 days + // = June 15 + 14 + 3 = July 2, then - 7 - 2 = June 23 + string input = "June 15, 2026 + 2 weeks 3 days - 1 week 2 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("6/23/2026", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_AllDateUnits() + { + CalculationService service = new(); + // January 1, 2020 + 1 decade 2 years 3 months 2 weeks 5 days + // = Jan 1 2030 + 2 years = Jan 1 2032 + 3 months = Apr 1 2032 + 14 days = Apr 15 + 5 days = Apr 20 + string input = "January 1, 2020 + 1 decade 2 years 3 months 2 weeks 5 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("4/20/2032", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_WithTimeInput() + { + CalculationService service = new(); + // 3/1/2026 8:00am + 1 day 4 hours 30 mins + // = 3/2/2026 8:00am + 4:30 = 12:30pm + string input = "3/1/2026 8:00am + 1 day 4 hours 30 mins"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Contains("3/2/2026", result.Output); + Assert.Contains("12:30pm", result.Output.ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_DoesNotBreakExplicitOperators() + { + CalculationService service = new(); + // Existing explicit-operator style should still work identically + string input = "January 1, 2026 + 2 weeks + 3 days"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("1/18/2026", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_DoesNotBreakRegularMath() + { + CalculationService service = new(); + string input = "5 + 3"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("8", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_DoesNotBreakQuantityWords() + { + CalculationService service = new(); + string input = "5 million + 3 thousand"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("5003000", result.Output.Replace(",", "")); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_RealWorld_Pregnancy() + { + CalculationService service = new(); + // Due date calculation: conception + 9 months 1 week + string input = "June 15, 2026 + 9 months 1 week"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + // June 15 + 9 months = March 15 2027, + 1 week = March 22 2027 + Assert.Equal("3/22/2027", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_RealWorld_SprintPlanning() + { + CalculationService service = new(); + // Sprint starts Monday 9am, lasts 2 weeks 3 days 6 hours + string input = "3/2/2026 9:00am + 2 weeks 3 days 6 hours"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + // March 2 + 14 + 3 = March 19, 9am + 6h = 3pm + Assert.Contains("3/19/2026", result.Output); + Assert.Contains("3:00pm", result.Output.ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_CombinedSegments_RealWorld_TravelItinerary() + { + CalculationService service = new(); + // Depart Jan 10 at 6:30am, travel 1 day 14 hours 45 mins + string input = "1/10/2026 6:30am + 1 day 14 hours 45 mins"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Jan 10 6:30am + 1d 14h 45m = Jan 11 6:30am + 14h 45m = Jan 11 9:15pm + Assert.Contains("1/11/2026", result.Output); + Assert.Contains("9:15pm", result.Output.ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + #endregion Combined Duration Segment Tests } diff --git a/Text-Grab/Services/CalculationService.DateTimeMath.cs b/Text-Grab/Services/CalculationService.DateTimeMath.cs index 97e66224..3a6b1908 100644 --- a/Text-Grab/Services/CalculationService.DateTimeMath.cs +++ b/Text-Grab/Services/CalculationService.DateTimeMath.cs @@ -10,6 +10,7 @@ public partial class CalculationService /// /// Attempts to evaluate a line as a date/time math expression. /// Supports expressions like "March 10th + 10 days", "2/25/26 11:02pm + 800 mins", etc. + /// Also supports combined duration segments: "today + 5 weeks 3 days 8 hours". /// Supported units: days, weeks, months, years, decades, hours, minutes. /// /// The input line to evaluate @@ -21,13 +22,13 @@ public static bool TryEvaluateDateTimeMath(string line, out string result) if (string.IsNullOrWhiteSpace(line)) return false; - // Find all arithmetic operations with time units - MatchCollection matches = DateTimeArithmeticPattern().Matches(line); - if (matches.Count == 0) + // Find the first explicit arithmetic operation (requires +/-) to anchor where arithmetic starts + Match anchorMatch = DateTimeArithmeticPattern().Match(line); + if (!anchorMatch.Success) return false; // Everything before the first arithmetic match is the date part - string datePart = line[..matches[0].Index].Trim(); + string datePart = line[..anchorMatch.Index].Trim(); // Parse the base date DateTime dateTime; @@ -43,21 +44,32 @@ public static bool TryEvaluateDateTimeMath(string line, out string result) return false; } - // First pass: collect operations and detect characteristics + // Parse all duration segments from the arithmetic portion. + // Supports both explicit operators (+ 5 days - 2 hours) and combined + // segments where implicit entries inherit the previous operator (+ 5 weeks 3 days 8 hours). + string arithmeticPortion = line[anchorMatch.Index..]; + MatchCollection segments = DateTimeDurationSegmentPattern().Matches(arithmeticPortion); + if (segments.Count == 0) + return false; + bool hasTimeUnits = false; bool hasFractionalDayOrLarger = false; List<(double Number, string Unit)> operations = []; + string currentOp = "+"; - foreach (Match match in matches) + foreach (Match segment in segments) { - string op = match.Groups["op"].Value; - string numberStr = match.Groups["number"].Value; - string unit = match.Groups["unit"].Value.ToLowerInvariant(); + string opValue = segment.Groups["op"].Value; + if (!string.IsNullOrEmpty(opValue)) + currentOp = opValue; + + string numberStr = segment.Groups["number"].Value; + string unit = segment.Groups["unit"].Value.ToLowerInvariant(); if (!double.TryParse(numberStr, NumberStyles.Any, CultureInfo.InvariantCulture, out double number)) return false; - if (op == "-") + if (currentOp == "-") number = -number; bool isTimeUnit = unit is "hour" or "hours" or "hr" or "hrs" @@ -74,7 +86,7 @@ public static bool TryEvaluateDateTimeMath(string line, out string result) if (hasFractionalDayOrLarger && !hasInputTime && dateTime.TimeOfDay == TimeSpan.Zero) dateTime = dateTime.AddHours(12); - // Second pass: apply all operations + // Apply all operations foreach ((double number, string unit) in operations) dateTime = ApplyDateTimeOffset(dateTime, number, unit); @@ -211,6 +223,9 @@ private static string FormatDateTimeResult(DateTime dateTime, bool includeTime) [System.Text.RegularExpressions.GeneratedRegex(@"(?[+-])\s*(?\d+\.?\d*)\s*(?decades?|years?|months?|weeks?|days?|hours?|hrs?|hr|minutes?|mins?|min)\b", System.Text.RegularExpressions.RegexOptions.IgnoreCase)] private static partial System.Text.RegularExpressions.Regex DateTimeArithmeticPattern(); + [System.Text.RegularExpressions.GeneratedRegex(@"(?[+-])?\s*(?\d+\.?\d*)\s*(?decades?|years?|months?|weeks?|days?|hours?|hrs?|hr|minutes?|mins?|min)\b", System.Text.RegularExpressions.RegexOptions.IgnoreCase)] + private static partial System.Text.RegularExpressions.Regex DateTimeDurationSegmentPattern(); + [System.Text.RegularExpressions.GeneratedRegex(@"(\d+)(?:st|nd|rd|th)\b", System.Text.RegularExpressions.RegexOptions.IgnoreCase)] private static partial System.Text.RegularExpressions.Regex OrdinalSuffixPattern(); From 70888bc10fa5fd6545aaad3b78a2a22e3efcf07f Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Thu, 26 Feb 2026 17:56:12 -0600 Subject: [PATCH 017/109] Enhance DateTime Math Evaluation and Subtraction Support - Added a new method to evaluate date/time math expressions with an optional base DateTime. - Implemented date subtraction functionality, allowing expressions like "March 10th - January 1st". - Introduced a human-readable format for the duration between two dates. - Updated the CalculationService to handle previous results for both numeric and date/time evaluations. - Improved handling of binary operators to allow continuation from previous results. --- Tests/CalculatorTests.cs | 1091 ++++++++++++----- .../CalculationService.DateTimeMath.cs | 120 +- Text-Grab/Services/CalculationService.cs | 55 +- 3 files changed, 968 insertions(+), 298 deletions(-) diff --git a/Tests/CalculatorTests.cs b/Tests/CalculatorTests.cs index d0c89596..b40a1601 100644 --- a/Tests/CalculatorTests.cs +++ b/Tests/CalculatorTests.cs @@ -1960,395 +1960,395 @@ public void ParseQuantityWords_ParsesCorrectly(string input, string expected) Assert.Equal(expected, result); } - #endregion ParseQuantityWords Direct Tests + #endregion ParseQuantityWords Direct Tests - #region Percentage Tests + #region Percentage Tests - [Theory] - [InlineData("4 * 25%", "1")] - [InlineData("4*25%", "1")] - [InlineData("100 * 15%", "15")] - [InlineData("200 * 50%", "100")] - [InlineData("80 * 10%", "8")] - public async Task Percentage_BasicMultiplication_ReturnsCorrectResult(string input, string expected) - { - // Arrange - CalculationService service = new(); + [Theory] + [InlineData("4 * 25%", "1")] + [InlineData("4*25%", "1")] + [InlineData("100 * 15%", "15")] + [InlineData("200 * 50%", "100")] + [InlineData("80 * 10%", "8")] + public async Task Percentage_BasicMultiplication_ReturnsCorrectResult(string input, string expected) + { + // Arrange + CalculationService service = new(); - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - Assert.Equal(expected, result.Output); - Assert.Equal(0, result.ErrorCount); - } + // Assert + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } - [Theory] - [InlineData("25%", "0.25")] - [InlineData("50%", "0.5")] - [InlineData("100%", "1")] - [InlineData("10%", "0.1")] - [InlineData("1%", "0.01")] - [InlineData("0.5%", "0.005")] - public async Task Percentage_Standalone_ConvertsToDecimal(string input, string expected) - { - // Arrange - CalculationService service = new(); + [Theory] + [InlineData("25%", "0.25")] + [InlineData("50%", "0.5")] + [InlineData("100%", "1")] + [InlineData("10%", "0.1")] + [InlineData("1%", "0.01")] + [InlineData("0.5%", "0.005")] + public async Task Percentage_Standalone_ConvertsToDecimal(string input, string expected) + { + // Arrange + CalculationService service = new(); - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - Assert.Equal(expected, result.Output); - Assert.Equal(0, result.ErrorCount); - } + // Assert + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } - [Theory] - [InlineData("25 %", "0.25")] - [InlineData("50 %", "0.5")] - [InlineData("15.5 %", "0.155")] - [InlineData("0.5 %", "0.005")] - public async Task Percentage_WithWhitespace_ConvertsCorrectly(string input, string expected) - { - // Arrange - CalculationService service = new(); + [Theory] + [InlineData("25 %", "0.25")] + [InlineData("50 %", "0.5")] + [InlineData("15.5 %", "0.155")] + [InlineData("0.5 %", "0.005")] + public async Task Percentage_WithWhitespace_ConvertsCorrectly(string input, string expected) + { + // Arrange + CalculationService service = new(); - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - Assert.Equal(expected, result.Output); - Assert.Equal(0, result.ErrorCount); - } + // Assert + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } - [Theory] - [InlineData("1000 + 20%", "1,000.2")] - [InlineData("100 - 10%", "99.9")] - [InlineData("50 / 25%", "200")] - public async Task Percentage_WithDifferentOperators_WorksCorrectly(string input, string expected) - { - // Arrange - CalculationService service = new(); + [Theory] + [InlineData("1000 + 20%", "1,000.2")] + [InlineData("100 - 10%", "99.9")] + [InlineData("50 / 25%", "200")] + public async Task Percentage_WithDifferentOperators_WorksCorrectly(string input, string expected) + { + // Arrange + CalculationService service = new(); - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - Assert.Equal(expected, result.Output); - Assert.Equal(0, result.ErrorCount); - } + // Assert + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } - [Fact] - public async Task Percentage_RealWorldExample_SalesTax() - { - // Arrange - CalculationService service = new(); - string input = @"price = 100 + [Fact] + public async Task Percentage_RealWorldExample_SalesTax() + { + // Arrange + CalculationService service = new(); + string input = @"price = 100 taxRate = 8.5% tax = price * taxRate total = price + tax total"; - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - string[] lines = result.Output.Split('\n'); - Assert.Equal("108.5", lines[^1]); - Assert.Equal(0, result.ErrorCount); - } + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal("108.5", lines[^1]); + Assert.Equal(0, result.ErrorCount); + } - [Fact] - public async Task Percentage_RealWorldExample_Discount() - { - // Arrange - CalculationService service = new(); - string input = @"originalPrice = 200 + [Fact] + public async Task Percentage_RealWorldExample_Discount() + { + // Arrange + CalculationService service = new(); + string input = @"originalPrice = 200 discount = 25% discountAmount = originalPrice * discount finalPrice = originalPrice - discountAmount finalPrice"; - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - string[] lines = result.Output.Split('\n'); - Assert.Equal("150", lines[^1]); - Assert.Equal(0, result.ErrorCount); - } + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal("150", lines[^1]); + Assert.Equal(0, result.ErrorCount); + } - [Fact] - public async Task Percentage_RealWorldExample_TipCalculation() - { - // Arrange - CalculationService service = new(); - string input = @"billAmount = 85 + [Fact] + public async Task Percentage_RealWorldExample_TipCalculation() + { + // Arrange + CalculationService service = new(); + string input = @"billAmount = 85 tipRate = 18% tip = billAmount * tipRate totalWithTip = billAmount + tip totalWithTip"; - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - string[] lines = result.Output.Split('\n'); - Assert.Equal("100.3", lines[^1]); - Assert.Equal(0, result.ErrorCount); - } + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal("100.3", lines[^1]); + Assert.Equal(0, result.ErrorCount); + } - [Fact] - public async Task Percentage_MultiplePercentages_WorksCorrectly() - { - // Arrange - CalculationService service = new(); - string input = @"100 * 10% + [Fact] + public async Task Percentage_MultiplePercentages_WorksCorrectly() + { + // Arrange + CalculationService service = new(); + string input = @"100 * 10% 200 * 15% 500 * 5%"; - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - string[] lines = result.Output.Split('\n'); - Assert.Equal(3, lines.Length); - Assert.Equal("10", lines[0]); - Assert.Equal("30", lines[1]); - Assert.Equal("25", lines[2]); - Assert.Equal(0, result.ErrorCount); - } + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(3, lines.Length); + Assert.Equal("10", lines[0]); + Assert.Equal("30", lines[1]); + Assert.Equal("25", lines[2]); + Assert.Equal(0, result.ErrorCount); + } - [Fact] - public async Task Percentage_WithVariables_WorksCorrectly() - { - // Arrange - CalculationService service = new(); - string input = @"base = 1000 + [Fact] + public async Task Percentage_WithVariables_WorksCorrectly() + { + // Arrange + CalculationService service = new(); + string input = @"base = 1000 rate = 15% result = base * rate result"; - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - string[] lines = result.Output.Split('\n'); - Assert.Equal("150", lines[^1]); - Assert.Equal(0, result.ErrorCount); - } + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal("150", lines[^1]); + Assert.Equal(0, result.ErrorCount); + } - [Fact] - public async Task Percentage_ComplexExpression_PercentageOfSum() - { - // Arrange - CalculationService service = new(); - string input = "(100 + 200) * 10%"; + [Fact] + public async Task Percentage_ComplexExpression_PercentageOfSum() + { + // Arrange + CalculationService service = new(); + string input = "(100 + 200) * 10%"; - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - Assert.Equal("30", result.Output); - Assert.Equal(0, result.ErrorCount); - } + // Assert + Assert.Equal("30", result.Output); + Assert.Equal(0, result.ErrorCount); + } - [Fact] - public async Task Percentage_CompoundPercentages_WorksCorrectly() - { - // Arrange - CalculationService service = new(); - string input = @"initial = 1000 + [Fact] + public async Task Percentage_CompoundPercentages_WorksCorrectly() + { + // Arrange + CalculationService service = new(); + string input = @"initial = 1000 afterFirst = initial * (1 - 20%) afterSecond = afterFirst * (1 + 15%) afterSecond"; - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - string[] lines = result.Output.Split('\n'); - // 1000 * 0.8 = 800, then 800 * 1.15 = 920 - Assert.Equal("920", lines[^1]); - Assert.Equal(0, result.ErrorCount); - } + // Assert + string[] lines = result.Output.Split('\n'); + // 1000 * 0.8 = 800, then 800 * 1.15 = 920 + Assert.Equal("920", lines[^1]); + Assert.Equal(0, result.ErrorCount); + } - [Theory] - [InlineData("-10%", "-0.1")] - [InlineData("4 * -25%", "-1")] - [InlineData("-100 * 10%", "-10")] - public async Task Percentage_NegativePercentages_WorksCorrectly(string input, string expected) - { - // Arrange - CalculationService service = new(); + [Theory] + [InlineData("-10%", "-0.1")] + [InlineData("4 * -25%", "-1")] + [InlineData("-100 * 10%", "-10")] + public async Task Percentage_NegativePercentages_WorksCorrectly(string input, string expected) + { + // Arrange + CalculationService service = new(); - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - Assert.Equal(expected, result.Output); - Assert.Equal(0, result.ErrorCount); - } + // Assert + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } - [Theory] - [InlineData("150%", "1.5")] - [InlineData("200%", "2")] - [InlineData("500%", "5")] - [InlineData("100 * 150%", "150")] - public async Task Percentage_OverHundredPercent_WorksCorrectly(string input, string expected) - { - // Arrange - CalculationService service = new(); + [Theory] + [InlineData("150%", "1.5")] + [InlineData("200%", "2")] + [InlineData("500%", "5")] + [InlineData("100 * 150%", "150")] + public async Task Percentage_OverHundredPercent_WorksCorrectly(string input, string expected) + { + // Arrange + CalculationService service = new(); - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - Assert.Equal(expected, result.Output); - Assert.Equal(0, result.ErrorCount); - } + // Assert + Assert.Equal(expected, result.Output); + Assert.Equal(0, result.ErrorCount); + } - [Fact] - public async Task Percentage_WithDecimalBase_WorksCorrectly() - { - // Arrange - CalculationService service = new(); - string input = "45.50 * 15%"; + [Fact] + public async Task Percentage_WithDecimalBase_WorksCorrectly() + { + // Arrange + CalculationService service = new(); + string input = "45.50 * 15%"; - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - Assert.Equal("6.825", result.Output); - Assert.Equal(0, result.ErrorCount); - } + // Assert + Assert.Equal("6.825", result.Output); + Assert.Equal(0, result.ErrorCount); + } - [Fact] - public async Task Percentage_WithDecimalPercentage_WorksCorrectly() - { - // Arrange - CalculationService service = new(); - string input = "100 * 12.5%"; + [Fact] + public async Task Percentage_WithDecimalPercentage_WorksCorrectly() + { + // Arrange + CalculationService service = new(); + string input = "100 * 12.5%"; - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - Assert.Equal("12.5", result.Output); - Assert.Equal(0, result.ErrorCount); - } + // Assert + Assert.Equal("12.5", result.Output); + Assert.Equal(0, result.ErrorCount); + } - [Theory] - [InlineData("25%", "0.25")] - [InlineData("25 %", "0.25")] - [InlineData("4 * 25%", "0.25")] - [InlineData("4 * 25 %", "0.25")] - public void ParsePercentages_DirectCall_ConvertsCorrectly(string input, string expectedContains) - { - // Arrange & Act - string result = CalculationService.ParsePercentages(input); + [Theory] + [InlineData("25%", "0.25")] + [InlineData("25 %", "0.25")] + [InlineData("4 * 25%", "0.25")] + [InlineData("4 * 25 %", "0.25")] + public void ParsePercentages_DirectCall_ConvertsCorrectly(string input, string expectedContains) + { + // Arrange & Act + string result = CalculationService.ParsePercentages(input); - // Assert - Assert.Contains(expectedContains, result); - } + // Assert + Assert.Contains(expectedContains, result); + } - [Fact] - public void ParsePercentages_EmptyInput_ReturnsEmpty() - { - // Arrange - string input = ""; + [Fact] + public void ParsePercentages_EmptyInput_ReturnsEmpty() + { + // Arrange + string input = ""; - // Act - string result = CalculationService.ParsePercentages(input); + // Act + string result = CalculationService.ParsePercentages(input); - // Assert - Assert.Equal("", result); - } + // Assert + Assert.Equal("", result); + } - [Fact] - public void ParsePercentages_NoPercentages_ReturnsUnchanged() - { - // Arrange - string input = "100 + 50"; + [Fact] + public void ParsePercentages_NoPercentages_ReturnsUnchanged() + { + // Arrange + string input = "100 + 50"; - // Act - string result = CalculationService.ParsePercentages(input); + // Act + string result = CalculationService.ParsePercentages(input); - // Assert - Assert.Equal("100 + 50", result); - } + // Assert + Assert.Equal("100 + 50", result); + } - [Fact] - public async Task Percentage_RealWorldExample_InterestCalculation() - { - // Arrange - CalculationService service = new(); - string input = @"principal = 10000 + [Fact] + public async Task Percentage_RealWorldExample_InterestCalculation() + { + // Arrange + CalculationService service = new(); + string input = @"principal = 10000 annualRate = 5% interest = principal * annualRate totalAmount = principal + interest totalAmount"; - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - string[] lines = result.Output.Split('\n'); - Assert.Equal("10,500", lines[^1]); - Assert.Equal(0, result.ErrorCount); - } + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal("10,500", lines[^1]); + Assert.Equal(0, result.ErrorCount); + } - [Fact] - public async Task Percentage_RealWorldExample_GradeCalculation() - { - // Arrange - CalculationService service = new(); - string input = @"totalQuestions = 50 + [Fact] + public async Task Percentage_RealWorldExample_GradeCalculation() + { + // Arrange + CalculationService service = new(); + string input = @"totalQuestions = 50 correctAnswers = 42 percentage = (correctAnswers / totalQuestions) * 100% percentage"; - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - string[] lines = result.Output.Split('\n'); - // (42/50) * 1 = 0.84 (since 100% = 1.0) - Assert.Equal("0.84", lines[^1]); - Assert.Equal(0, result.ErrorCount); - } + // Assert + string[] lines = result.Output.Split('\n'); + // (42/50) * 1 = 0.84 (since 100% = 1.0) + Assert.Equal("0.84", lines[^1]); + Assert.Equal(0, result.ErrorCount); + } - [Fact] - public async Task Percentage_WithMathFunctions_WorksCorrectly() - { - // Arrange - CalculationService service = new(); - string input = "Sqrt(100) * 25%"; + [Fact] + public async Task Percentage_WithMathFunctions_WorksCorrectly() + { + // Arrange + CalculationService service = new(); + string input = "Sqrt(100) * 25%"; - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - Assert.Equal("2.5", result.Output); - Assert.Equal(0, result.ErrorCount); - } + // Assert + Assert.Equal("2.5", result.Output); + Assert.Equal(0, result.ErrorCount); + } - [Fact] - public async Task Percentage_WithSum_WorksCorrectly() - { - // Arrange - CalculationService service = new(); - string input = "Sum(100, 200, 300) * 10%"; + [Fact] + public async Task Percentage_WithSum_WorksCorrectly() + { + // Arrange + CalculationService service = new(); + string input = "Sum(100, 200, 300) * 10%"; - // Act - CalculationResult result = await service.EvaluateExpressionsAsync(input); + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); - // Assert - Assert.Equal("60", result.Output); - Assert.Equal(0, result.ErrorCount); - } + // Assert + Assert.Equal("60", result.Output); + Assert.Equal(0, result.ErrorCount); + } - #endregion Percentage Tests + #endregion Percentage Tests #region DateTime Math Tests @@ -2991,4 +2991,505 @@ public async Task DateTimeMath_CombinedSegments_RealWorld_TravelItinerary() } #endregion Combined Duration Segment Tests + + #region Date Subtraction (Date - Date = Timespan) Tests + + [Fact] + public async Task DateTimeMath_DateSubtraction_TwoDatesYieldTimespan() + { + CalculationService service = new(); + // March 10, 2026 - January 1, 2026 = 2 months 1 week 2 days + string input = "March 10, 2026 - January 1, 2026"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Contains("2 months", result.Output); + Assert.Contains("1 week", result.Output); + Assert.Contains("2 days", result.Output); + Assert.Equal(0, result.ErrorCount); } + + [Fact] + public async Task DateTimeMath_DateSubtraction_SameDateReturnsZeroSeconds() + { + CalculationService service = new(); + string input = "January 1, 2026 - January 1, 2026"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("0 seconds", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_DateSubtraction_ReversedOrderStillWorks() + { + CalculationService service = new(); + // Earlier date first should still give positive result + string input = "January 1, 2026 - March 10, 2026"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Contains("2 months", result.Output); + Assert.Contains("1 week", result.Output); + Assert.Contains("2 days", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_DateSubtraction_WithKeywords() + { + CalculationService service = new(); + // today - yesterday = 1 day + string input = "today - yesterday"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("1 day", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_DateSubtraction_ExactlyOneYear() + { + CalculationService service = new(); + string input = "January 1, 2027 - January 1, 2026"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("1 year", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_DateSubtraction_MultipleYearsMonthsDays() + { + CalculationService service = new(); + // July 15, 2028 - January 1, 2026 = 2 years 6 months 2 weeks + string input = "July 15, 2028 - January 1, 2026"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Contains("2 years", result.Output); + Assert.Contains("6 months", result.Output); + Assert.Contains("2 weeks", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_DateSubtraction_WithTimes_IncludesHoursMinutesSeconds() + { + CalculationService service = new(); + // 3/1/2026 10:30:45am - 3/1/2026 8:00:00am = 2 hours 30 minutes 45 seconds + string input = "3/1/2026 10:30:45am - 3/1/2026 8:00:00am"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Contains("2 hours", result.Output); + Assert.Contains("30 minutes", result.Output); + Assert.Contains("45 seconds", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_DateSubtraction_NumericDateFormat() + { + CalculationService service = new(); + // 6/15/2026 - 6/1/2026 = 2 weeks + string input = "6/15/2026 - 6/1/2026"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("2 weeks", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_DateSubtraction_OneDayDifference() + { + CalculationService service = new(); + string input = "March 2, 2026 - March 1, 2026"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Equal("1 day", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_DateSubtraction_WeeksAndDays() + { + CalculationService service = new(); + // 10 days = 1 week 3 days + string input = "January 11, 2026 - January 1, 2026"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Contains("1 week", result.Output); + Assert.Contains("3 days", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DateTimeMath_DateSubtraction_SingularUnits() + { + CalculationService service = new(); + // 1 year 1 month 1 week 1 day + // Feb 1 2026 + 1y = Feb 1 2027, + 1m = Mar 1 2027, + 1w = Mar 8, + 1d = Mar 9 + string input = "March 9, 2027 - February 1, 2026"; + CalculationResult result = await service.EvaluateExpressionsAsync(input); + Assert.Contains("1 year", result.Output); + Assert.Contains("1 month", result.Output); + Assert.Contains("1 week", result.Output); + Assert.Contains("1 day", result.Output); + Assert.DoesNotContain("years", result.Output); + Assert.DoesNotContain("months", result.Output); + Assert.DoesNotContain("weeks", result.Output); + Assert.DoesNotContain("days", result.Output); + Assert.Equal(0, result.ErrorCount); + } + + #endregion Date Subtraction (Date - Date = Timespan) Tests + + #region Date Operator Continuation Tests + + [Fact] + public async Task CalculationService_DatePlusDuration_ThenOperatorContinuation() + { + // Arrange - "March 1, 2026 + 2 weeks" then "+ 1 month" should chain + CalculationService service = new(); + string input = "March 1, 2026 + 2 weeks\n+ 1 month"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(2, lines.Length); + // March 1 + 2 weeks = March 15 + Assert.Contains("3/15/2026", lines[0]); + // March 15 + 1 month = April 15 + Assert.Contains("4/15/2026", lines[1]); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_DateChainedThreeLines() + { + // Arrange - chain three date operations + CalculationService service = new(); + string input = "January 1, 2026 + 1 month\n+ 1 month\n+ 1 month"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(3, lines.Length); + Assert.Contains("2/1/2026", lines[0]); // Jan 1 + 1 month = Feb 1 + Assert.Contains("3/1/2026", lines[1]); // Feb 1 + 1 month = Mar 1 + Assert.Contains("4/1/2026", lines[2]); // Mar 1 + 1 month = Apr 1 + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_DateMinusDuration_OperatorContinuation() + { + // Arrange - subtract duration from previous date result + CalculationService service = new(); + string input = "March 15, 2026 + 1 month\n- 1 week"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Contains("4/15/2026", lines[0]); // March 15 + 1 month = April 15 + Assert.Contains("4/8/2026", lines[1]); // April 15 - 1 week = April 8 + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_DateWithTime_OperatorContinuation() + { + // Arrange - date with time then add hours + CalculationService service = new(); + string input = "March 1, 2026 10:00am + 5 hours\n+ 3 hours"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(2, lines.Length); + // March 1 10am + 5 hours = March 1 3pm + Assert.Contains("3:00pm", lines[0].ToLowerInvariant()); + // 3pm + 3 hours = 6pm + Assert.Contains("6:00pm", lines[1].ToLowerInvariant()); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_DateContinuation_CommentDoesNotResetDate() + { + // Arrange - comment between date lines should preserve the date + CalculationService service = new(); + string input = "January 1, 2026 + 1 month\n// add another month\n+ 1 month"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(3, lines.Length); + Assert.Contains("2/1/2026", lines[0]); // Jan 1 + 1 month = Feb 1 + Assert.Equal("", lines[1]); // comment + Assert.Contains("3/1/2026", lines[2]); // Feb 1 + 1 month = Mar 1 + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_DateFollowedByNumericExpression_ResetsDateContext() + { + // Arrange - numeric expression after date should not carry date forward + CalculationService service = new(); + string input = "January 1, 2026 + 1 month\n5 + 3"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Contains("2/1/2026", lines[0]); + Assert.Equal("8", lines[1]); + Assert.Equal(0, result.ErrorCount); + } + + #endregion Date Operator Continuation Tests + + #region Operator Continuation Tests + + [Fact] + public async Task CalculationService_LineStartingWithMultiply_UsesPreviousResult() + { + // Arrange + CalculationService service = new(); + string input = "2 + 3\n* 4"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Equal("5", lines[0]); // 2 + 3 = 5 + Assert.Equal("20", lines[1]); // 5 * 4 = 20 + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_LineStartingWithDivide_UsesPreviousResult() + { + // Arrange + CalculationService service = new(); + string input = "100\n/ 4"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Equal("100", lines[0]); + Assert.Equal("25", lines[1]); // 100 / 4 = 25 + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_LineStartingWithPlus_UsesPreviousResult() + { + // Arrange + CalculationService service = new(); + string input = "10\n+ 5"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Equal("10", lines[0]); + Assert.Equal("15", lines[1]); // 10 + 5 = 15 + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_LineStartingWithMinus_UsesPreviousResult() + { + // Arrange + CalculationService service = new(); + string input = "10\n- 3"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Equal("10", lines[0]); + Assert.Equal("7", lines[1]); // 10 - 3 = 7 + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_ChainedOperatorContinuation_AccumulatesResults() + { + // Arrange - running total: 10 -> 20 -> 15 -> 45 + CalculationService service = new(); + string input = "10\n+ 10\n- 5\n* 3"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(4, lines.Length); + Assert.Equal("10", lines[0]); // 10 + Assert.Equal("20", lines[1]); // 10 + 10 = 20 + Assert.Equal("15", lines[2]); // 20 - 5 = 15 + Assert.Equal("45", lines[3]); // 15 * 3 = 45 + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_OperatorAfterVariableAssignment_UsesAssignmentValue() + { + // Arrange + CalculationService service = new(); + string input = "x = 10\n* 2"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Contains("x = 10", lines[0]); + Assert.Equal("20", lines[1]); // 10 * 2 = 20 + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_OperatorWithNoPreviousResult_FirstLine() + { + // Arrange - + with space but no previous result on first line + // This will try to evaluate "+ 5" which NCalc can't handle + CalculationService service = new() { ShowErrors = true }; + string input = "+ 5"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert - should produce an error since there's no left operand + Assert.Contains("Error", result.Output); + Assert.Equal(1, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_OperatorSkipsComments_UsesPreviousResult() + { + // Arrange - comments/empty lines don't reset previous result + CalculationService service = new(); + string input = "10\n// this is a comment\n+ 5"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(3, lines.Length); + Assert.Equal("10", lines[0]); + Assert.Equal("", lines[1]); // comment line + Assert.Equal("15", lines[2]); // 10 + 5 = 15 + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_OperatorAfterExpression_UsesPreviousOutput() + { + // Arrange - operator continues from previous expression output + CalculationService service = new(); + string input = "3 * 4\n+ 8"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Equal("12", lines[0]); // 3 * 4 = 12 + Assert.Equal("20", lines[1]); // 12 + 8 = 20 + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_OperatorWithParentheses_UsesPreviousResult() + { + // Arrange + CalculationService service = new(); + string input = "10\n* (2 + 3)"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Equal("10", lines[0]); + Assert.Equal("50", lines[1]); // 10 * (2 + 3) = 50 + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_MultiplyWithNoSpace_UsesPreviousResult() + { + // Arrange - *2 (no space) should still work for * since it can't be unary + CalculationService service = new(); + string input = "10\n*2"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Equal("10", lines[0]); + Assert.Equal("20", lines[1]); // 10 * 2 = 20 + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task CalculationService_OperatorWithDecimalPreviousResult() + { + // Arrange - previous result is a decimal + CalculationService service = new(); + string input = "2.5\n* 4"; + + // Act + CalculationResult result = await service.EvaluateExpressionsAsync(input); + + // Assert + string[] lines = result.Output.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Equal("2.5", lines[0]); + Assert.Equal("10", lines[1]); // 2.5 * 4 = 10 + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public void StartsWithBinaryOperator_ValidOperators_ReturnsTrue() + { + Assert.True(CalculationService.StartsWithBinaryOperator("+ 5")); + Assert.True(CalculationService.StartsWithBinaryOperator("- 3")); + Assert.True(CalculationService.StartsWithBinaryOperator("* 2")); + Assert.True(CalculationService.StartsWithBinaryOperator("/ 4")); + Assert.True(CalculationService.StartsWithBinaryOperator("*2")); + Assert.True(CalculationService.StartsWithBinaryOperator("*(2+3)")); + } + + [Fact] + public void StartsWithBinaryOperator_InvalidPatterns_ReturnsFalse() + { + Assert.False(CalculationService.StartsWithBinaryOperator("")); + Assert.False(CalculationService.StartsWithBinaryOperator("5 + 3")); + Assert.False(CalculationService.StartsWithBinaryOperator("x")); + Assert.False(CalculationService.StartsWithBinaryOperator("+")); // single char, too short + Assert.False(CalculationService.StartsWithBinaryOperator("+5")); // unary plus (no space) + Assert.False(CalculationService.StartsWithBinaryOperator("-3")); // negative number (no space) + Assert.False(CalculationService.StartsWithBinaryOperator("-10 + 5")); // negative number expression + } + + #endregion Operator Continuation Tests +} diff --git a/Text-Grab/Services/CalculationService.DateTimeMath.cs b/Text-Grab/Services/CalculationService.DateTimeMath.cs index 3a6b1908..2593fb3b 100644 --- a/Text-Grab/Services/CalculationService.DateTimeMath.cs +++ b/Text-Grab/Services/CalculationService.DateTimeMath.cs @@ -17,11 +17,30 @@ public partial class CalculationService /// The formatted date/time result if successful /// True if the line was successfully evaluated as a date/time math expression public static bool TryEvaluateDateTimeMath(string line, out string result) + { + return TryEvaluateDateTimeMath(line, out result, out _, null); + } + + /// + /// Attempts to evaluate a line as a date/time math expression, optionally using a previous + /// DateTime result as the base when the line starts with an operator (e.g. "+ 2 weeks"). + /// + /// The input line to evaluate + /// The formatted date/time result if successful + /// The parsed DateTime value of the result, for use as a base in subsequent lines + /// An optional base DateTime from a previous line's result, used when the date part is empty + /// True if the line was successfully evaluated as a date/time math expression + public static bool TryEvaluateDateTimeMath(string line, out string result, out DateTime? parsedDateTime, DateTime? baseDateTime) { result = string.Empty; + parsedDateTime = null; if (string.IsNullOrWhiteSpace(line)) return false; + // Try date subtraction first (date - date = timespan) + if (TryEvaluateDateSubtraction(line, out result)) + return true; + // Find the first explicit arithmetic operation (requires +/-) to anchor where arithmetic starts Match anchorMatch = DateTimeArithmeticPattern().Match(line); if (!anchorMatch.Success) @@ -36,8 +55,17 @@ public static bool TryEvaluateDateTimeMath(string line, out string result) if (string.IsNullOrEmpty(datePart)) { - dateTime = DateTime.Today; - hasInputTime = false; + if (baseDateTime.HasValue) + { + // Use the previous line's DateTime result as the base + dateTime = baseDateTime.Value; + hasInputTime = dateTime.TimeOfDay != TimeSpan.Zero; + } + else + { + dateTime = DateTime.Today; + hasInputTime = false; + } } else if (!TryParseFlexibleDate(datePart, out dateTime, out hasInputTime)) { @@ -95,6 +123,7 @@ public static bool TryEvaluateDateTimeMath(string line, out string result) (hasFractionalDayOrLarger && dateTime.TimeOfDay != TimeSpan.Zero); result = FormatDateTimeResult(dateTime, showTime); + parsedDateTime = dateTime; return true; } @@ -220,6 +249,90 @@ private static string FormatDateTimeResult(DateTime dateTime, bool includeTime) return dateTime.ToString("d", culture); } + /// + /// Attempts to evaluate a line as a date subtraction expression (date - date = timespan). + /// Supports expressions like "March 10th - January 1st", "today - yesterday", etc. + /// Returns the duration between the two dates in whole units down to seconds. + /// + private static bool TryEvaluateDateSubtraction(string line, out string result) + { + result = string.Empty; + + MatchCollection matches = DateSubtractionSplitPattern().Matches(line); + if (matches.Count == 0) + return false; + + foreach (Match splitMatch in matches) + { + string leftPart = line[..splitMatch.Index].Trim(); + string rightPart = line[(splitMatch.Index + splitMatch.Length)..].Trim(); + + if (string.IsNullOrEmpty(leftPart) || string.IsNullOrEmpty(rightPart)) + continue; + + if (!TryParseFlexibleDate(leftPart, out DateTime date1, out _)) + continue; + if (!TryParseFlexibleDate(rightPart, out DateTime date2, out _)) + continue; + + DateTime earlier, later; + if (date1 >= date2) + { + later = date1; + earlier = date2; + } + else + { + later = date2; + earlier = date1; + } + + result = FormatTimeSpanHumanReadable(earlier, later); + return true; + } + + return false; + } + + /// + /// Formats the difference between two dates as a human-readable string + /// with whole units from years down to seconds (e.g., "2 weeks 3 days 2 hours"). + /// Only non-zero components are included. + /// + private static string FormatTimeSpanHumanReadable(DateTime earlier, DateTime later) + { + int years = later.Year - earlier.Year; + DateTime temp = earlier.AddYears(years); + if (temp > later) + { + years--; + temp = earlier.AddYears(years); + } + + int months = 0; + while (temp.AddMonths(months + 1) <= later) + months++; + temp = temp.AddMonths(months); + + TimeSpan remaining = later - temp; + int weeks = remaining.Days / 7; + int days = remaining.Days % 7; + int hours = remaining.Hours; + int minutes = remaining.Minutes; + int seconds = remaining.Seconds; + + List parts = []; + if (years > 0) parts.Add($"{years} {(years == 1 ? "year" : "years")}"); + if (months > 0) parts.Add($"{months} {(months == 1 ? "month" : "months")}"); + if (weeks > 0) parts.Add($"{weeks} {(weeks == 1 ? "week" : "weeks")}"); + if (days > 0) parts.Add($"{days} {(days == 1 ? "day" : "days")}"); + if (hours > 0) parts.Add($"{hours} {(hours == 1 ? "hour" : "hours")}"); + if (minutes > 0) parts.Add($"{minutes} {(minutes == 1 ? "minute" : "minutes")}"); + if (seconds > 0) parts.Add($"{seconds} {(seconds == 1 ? "second" : "seconds")}"); + + return parts.Count == 0 ? "0 seconds" : string.Join(" ", parts); + } + [System.Text.RegularExpressions.GeneratedRegex(@"(?[+-])\s*(?\d+\.?\d*)\s*(?decades?|years?|months?|weeks?|days?|hours?|hrs?|hr|minutes?|mins?|min)\b", System.Text.RegularExpressions.RegexOptions.IgnoreCase)] private static partial System.Text.RegularExpressions.Regex DateTimeArithmeticPattern(); @@ -234,4 +347,7 @@ private static string FormatDateTimeResult(DateTime dateTime, bool includeTime) [System.Text.RegularExpressions.GeneratedRegex(@"\d{1,2}:\d{2}")] private static partial System.Text.RegularExpressions.Regex ColonTimePattern(); + + [System.Text.RegularExpressions.GeneratedRegex(@"\s+-\s+")] + private static partial System.Text.RegularExpressions.Regex DateSubtractionSplitPattern(); } diff --git a/Text-Grab/Services/CalculationService.cs b/Text-Grab/Services/CalculationService.cs index 76601016..d3a5a70f 100644 --- a/Text-Grab/Services/CalculationService.cs +++ b/Text-Grab/Services/CalculationService.cs @@ -46,6 +46,8 @@ public async Task EvaluateExpressionsAsync(string input) List results = []; List outputNumbers = []; int errorCount = 0; + double? previousLineResult = null; + DateTime? previousDateTimeResult = null; // Clear parameters and rebuild from scratch for each evaluation _parameters.Clear(); @@ -59,16 +61,27 @@ public async Task EvaluateExpressionsAsync(string input) continue; } + // If the line starts with a binary operator and we have a previous numeric result, + // prepend the previous result to form a complete expression + if (previousLineResult.HasValue && !previousDateTimeResult.HasValue && StartsWithBinaryOperator(trimmedLine)) + { + string previousValueStr = previousLineResult.Value.ToString(CultureInfo.InvariantCulture); + trimmedLine = previousValueStr + " " + trimmedLine; + } + try { - if (TryEvaluateDateTimeMath(trimmedLine, out string dateTimeResult)) + if (TryEvaluateDateTimeMath(trimmedLine, out string dateTimeResult, out DateTime? parsedDateTime, previousDateTimeResult)) { results.Add(dateTimeResult); + previousDateTimeResult = parsedDateTime; + previousLineResult = null; } else if (IsParameterAssignment(trimmedLine)) { string resultLine = await HandleParameterAssignmentAsync(trimmedLine); results.Add(resultLine); + previousDateTimeResult = null; // Extract variable name and add its value to output numbers int equalIndex = trimmedLine.IndexOf('='); @@ -79,17 +92,24 @@ public async Task EvaluateExpressionsAsync(string input) { double numValue = Convert.ToDouble(value); outputNumbers.Add(numValue); + previousLineResult = numValue; } catch { // Skip non-numeric values + previousLineResult = null; } } + else + { + previousLineResult = null; + } } else { string resultLine = await EvaluateStandardExpressionAsync(trimmedLine); results.Add(resultLine); + previousDateTimeResult = null; // Try to parse the result as a number and add to output numbers // Remove formatting characters before parsing @@ -97,6 +117,11 @@ public async Task EvaluateExpressionsAsync(string input) if (double.TryParse(cleanedResult, NumberStyles.Any, CultureInfo.InvariantCulture, out double numValue)) { outputNumbers.Add(numValue); + previousLineResult = numValue; + } + else + { + previousLineResult = null; } } } @@ -111,6 +136,8 @@ public async Task EvaluateExpressionsAsync(string input) results.Add(""); // Empty line when errors are hidden } errorCount++; + previousLineResult = null; + previousDateTimeResult = null; } } @@ -551,6 +578,32 @@ private static void RegisterCustomFunctions(AsyncExpression expression) }; } + /// + /// Checks if a line starts with a binary operator that could continue from a previous result. + /// For * / (which cannot be unary), matches when followed by a space, digit, paren, or letter. + /// For + - (which can be unary), only matches when followed by a space to distinguish from + /// negative numbers like "-3" or unary plus like "+5". + /// + public static bool StartsWithBinaryOperator(string trimmedLine) + { + if (string.IsNullOrEmpty(trimmedLine) || trimmedLine.Length < 2) + return false; + + char first = trimmedLine[0]; + char second = trimmedLine[1]; + + // * / always require a left operand — treat as continuation operator + if (first is '*' or '/') + return second == ' ' || char.IsDigit(second) || second == '(' || char.IsLetter(second); + + // + - can be unary, so only treat as continuation when followed by a space + // This distinguishes "- 3" (continuation) from "-3" (negative number) + if (first is '+' or '-') + return second == ' '; + + return false; + } + [System.Text.RegularExpressions.GeneratedRegex(@"(\d)\.(?=\d{3}(?:\.|,|\D|$))")] private static partial System.Text.RegularExpressions.Regex DigitGroupSeparator(); [System.Text.RegularExpressions.GeneratedRegex(@"(\d),(?=\d{3}(?:,|\D|$))")] From 8da968c5b27b1c544a5cab750d132c656458b957 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Thu, 26 Feb 2026 18:40:31 -0600 Subject: [PATCH 018/109] Add unit conversion functionality and corresponding unit tests - Implemented CalculationService.UnitMath to handle various unit conversions including length, mass, temperature, volume, speed, and area. - Created a comprehensive unit test suite in UnitConversionTests.cs to validate conversion accuracy and edge cases. - Added support for explicit conversions, continuation conversions, and operator continuations. - Enhanced unit recognition with plural forms and aliases, including British spellings. - Established regex patterns for parsing unit expressions and operations. --- Tests/UnitConversionTests.cs | 445 +++++++++++++ .../Services/CalculationService.UnitMath.cs | 602 ++++++++++++++++++ Text-Grab/Services/CalculationService.cs | 46 +- Text-Grab/Text-Grab.csproj | 1 + Text-Grab/Views/EditTextWindow.xaml | 478 +++++++------- 5 files changed, 1353 insertions(+), 219 deletions(-) create mode 100644 Tests/UnitConversionTests.cs create mode 100644 Text-Grab/Services/CalculationService.UnitMath.cs diff --git a/Tests/UnitConversionTests.cs b/Tests/UnitConversionTests.cs new file mode 100644 index 00000000..90f8ef34 --- /dev/null +++ b/Tests/UnitConversionTests.cs @@ -0,0 +1,445 @@ +using System.Globalization; +using Text_Grab.Services; + +namespace Tests; + +public class UnitConversionTests +{ + private readonly CalculationService _service = new(); + + #region Explicit Conversion Tests + + [Theory] + [InlineData("5 miles to km", "km")] + [InlineData("100 fahrenheit to celsius", "°C")] + [InlineData("1 kg to pounds", "lb")] + [InlineData("3.5 gallons to liters", "L")] + [InlineData("60 mph to km/h", "km/h")] + [InlineData("1 acre to sq m", "m²")] + [InlineData("12 inches to feet", "ft")] + [InlineData("1000 grams to kg", "kg")] + [InlineData("50 celsius to fahrenheit", "°F")] + [InlineData("1 nautical mile to km", "km")] + public async Task ExplicitConversion_ContainsTargetUnit(string input, string expectedUnit) + { + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Contains(expectedUnit, result.Output); + Assert.Equal(0, result.ErrorCount); + } + + [Theory] + [InlineData("5 miles to km", 8.047, 0.01)] + [InlineData("1 kg to pounds", 2.205, 0.01)] + [InlineData("100 fahrenheit to celsius", 37.778, 0.01)] + [InlineData("0 celsius to fahrenheit", 32, 0.01)] + [InlineData("1 foot to inches", 12, 0.01)] + [InlineData("1 mile to feet", 5280, 1)] + [InlineData("1 gallon to liters", 3.785, 0.01)] + [InlineData("1 kg to grams", 1000, 0.01)] + [InlineData("100 cm to meters", 1, 0.01)] + [InlineData("1 tonne to kg", 1000, 0.01)] + public async Task ExplicitConversion_CorrectNumericValue(string input, double expectedValue, double tolerance) + { + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Single(result.OutputNumbers); + Assert.InRange(result.OutputNumbers[0], expectedValue - tolerance, expectedValue + tolerance); + } + + [Theory] + [InlineData("5 in to cm")] // "in" is both inches and keyword — "to" takes priority + [InlineData("10 ft to m")] + [InlineData("3 yd to meters")] + public async Task ExplicitConversion_WithShortAbbreviations(string input) + { + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Equal(0, result.ErrorCount); + Assert.Single(result.OutputNumbers); + } + + [Fact] + public async Task ExplicitConversion_InKeyword_Works() + { + CalculationResult result = await _service.EvaluateExpressionsAsync("5 gallons in liters"); + + Assert.Contains("L", result.Output); + Assert.Equal(0, result.ErrorCount); + Assert.Single(result.OutputNumbers); + Assert.InRange(result.OutputNumbers[0], 18.9, 18.95); + } + + [Fact] + public async Task ExplicitConversion_ZeroValue_Works() + { + CalculationResult result = await _service.EvaluateExpressionsAsync("0 km to miles"); + + Assert.Contains("mi", result.Output); + Assert.Single(result.OutputNumbers); + Assert.Equal(0, result.OutputNumbers[0], 3); + } + + [Fact] + public async Task ExplicitConversion_NegativeValue_Works() + { + CalculationResult result = await _service.EvaluateExpressionsAsync("-40 celsius to fahrenheit"); + + Assert.Contains("°F", result.Output); + Assert.Single(result.OutputNumbers); + Assert.Equal(-40, result.OutputNumbers[0], 1); + } + + [Fact] + public async Task ExplicitConversion_IncompatibleTypes_FallsThrough() + { + // "5 kg to km" — mass to length should not convert + CalculationResult result = await _service.EvaluateExpressionsAsync("5 kg to km"); + + // Should not produce a clean unit result — falls through to NCalc (which will error) + Assert.True(result.ErrorCount > 0 || !result.Output.Contains("km")); + } + + #endregion Explicit Conversion Tests + + #region Continuation Conversion Tests + + [Fact] + public async Task ContinuationConversion_ToKeyword() + { + string input = "5 miles\nto km"; + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + string[] lines = result.Output.Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Contains("mi", lines[0]); + Assert.Contains("km", lines[1]); + } + + [Fact] + public async Task ContinuationConversion_CorrectValue() + { + string input = "100 celsius\nto fahrenheit"; + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Equal(2, result.OutputNumbers.Count); + Assert.Equal(100, result.OutputNumbers[0], 1); + Assert.Equal(212, result.OutputNumbers[1], 1); + } + + [Fact] + public async Task ContinuationConversion_ChainedConversions() + { + string input = "1 mile\nto km\nto meters"; + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Equal(3, result.OutputNumbers.Count); + // 1 mile → 1.609 km → 1609.34 m + Assert.InRange(result.OutputNumbers[2], 1609, 1610); + } + + #endregion Continuation Conversion Tests + + #region Operator Continuation Tests + + [Fact] + public async Task OperatorContinuation_AddSameUnit() + { + string input = "5 km\n+ 3 km"; + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Equal(2, result.OutputNumbers.Count); + Assert.Equal(8, result.OutputNumbers[1], 1); + Assert.Contains("km", result.Output.Split('\n')[1]); + } + + [Fact] + public async Task OperatorContinuation_SubtractSameUnit() + { + string input = "10 kg\n- 3 kg"; + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Equal(2, result.OutputNumbers.Count); + Assert.Equal(7, result.OutputNumbers[1], 1); + } + + [Fact] + public async Task OperatorContinuation_AddDifferentUnit_SameType() + { + string input = "5 km\n+ 3 miles"; + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Equal(2, result.OutputNumbers.Count); + // 3 miles ≈ 4.828 km, so 5 + 4.828 ≈ 9.828 km + Assert.InRange(result.OutputNumbers[1], 9.8, 9.9); + Assert.Contains("km", result.Output.Split('\n')[1]); + } + + [Fact] + public async Task ScaleOperator_Multiply() + { + string input = "5 km\n* 3"; + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Equal(2, result.OutputNumbers.Count); + Assert.Equal(15, result.OutputNumbers[1], 1); + Assert.Contains("km", result.Output.Split('\n')[1]); + } + + [Fact] + public async Task ScaleOperator_Divide() + { + string input = "10 meters\n/ 2"; + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Equal(2, result.OutputNumbers.Count); + Assert.Equal(5, result.OutputNumbers[1], 1); + Assert.Contains("m", result.Output.Split('\n')[1]); + } + + [Fact] + public async Task OperatorContinuation_ThenConvert() + { + string input = "5 km\n+ 3 km\nto miles"; + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Equal(3, result.OutputNumbers.Count); + // 8 km ≈ 4.971 miles + Assert.InRange(result.OutputNumbers[2], 4.9, 5.0); + Assert.Contains("mi", result.Output.Split('\n')[2]); + } + + #endregion Operator Continuation Tests + + #region Standalone Unit Tests + + [Theory] + [InlineData("5 meters", "m")] + [InlineData("100 kg", "kg")] + [InlineData("3.5 gallons", "gal")] + [InlineData("10 miles", "mi")] + [InlineData("25 mph", "mph")] + public async Task StandaloneUnit_DetectedAndDisplayed(string input, string expectedAbbrev) + { + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Contains(expectedAbbrev, result.Output); + Assert.Single(result.OutputNumbers); + Assert.Equal(0, result.ErrorCount); + } + + [Theory] + [InlineData("5 meters", 5)] + [InlineData("100 kg", 100)] + [InlineData("3.5 gallons", 3.5)] + public async Task StandaloneUnit_CorrectNumericValue(string input, double expected) + { + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Single(result.OutputNumbers); + Assert.Equal(expected, result.OutputNumbers[0], 3); + } + + #endregion Standalone Unit Tests + + #region Unit Category Tests + + [Theory] + // Length + [InlineData("1 meter to feet", "ft")] + [InlineData("1 km to miles", "mi")] + [InlineData("1 inch to cm", "cm")] + [InlineData("1 yard to meters", "m")] + [InlineData("1 nautical mile to km", "km")] + // Mass + [InlineData("1 kg to pounds", "lb")] + [InlineData("1 ounce to grams", "g")] + [InlineData("1 stone to kg", "kg")] + [InlineData("1 ton to kg", "kg")] + [InlineData("1 tonne to pounds", "lb")] + // Temperature + [InlineData("100 celsius to fahrenheit", "°F")] + [InlineData("212 fahrenheit to celsius", "°C")] + [InlineData("0 celsius to kelvin", "K")] + // Volume + [InlineData("1 gallon to liters", "L")] + [InlineData("1 cup to mL", "mL")] + [InlineData("1 tablespoon to teaspoons", "tsp")] + [InlineData("1 pint to cups", "cup")] + [InlineData("1 quart to pints", "pt")] + [InlineData("1 fl oz to mL", "mL")] + // Speed + [InlineData("60 mph to km/h", "km/h")] + [InlineData("100 km/h to mph", "mph")] + [InlineData("1 m/s to km/h", "km/h")] + [InlineData("1 knot to mph", "mph")] + // Area + [InlineData("1 acre to sq m", "m²")] + [InlineData("1 hectare to acres", "ac")] + [InlineData("1 sq mi to sq km", "km²")] + [InlineData("1 sq ft to sq m", "m²")] + public async Task AllUnitCategories_ConvertSuccessfully(string input, string expectedUnit) + { + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Contains(expectedUnit, result.Output); + Assert.Equal(0, result.ErrorCount); + Assert.Single(result.OutputNumbers); + } + + #endregion Unit Category Tests + + #region Ambiguity & Edge Case Tests + + [Fact] + public async Task VariableTakesPriorityOverUnit() + { + // When a variable "km" is defined, "5 km" should use the variable, not the unit + string input = "km = 10\n5 * km"; + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + // 5 * 10 = 50 (not "5 km") + Assert.Equal(2, result.OutputNumbers.Count); + Assert.Equal(50, result.OutputNumbers[1], 1); + } + + [Fact] + public async Task QuantityWords_StillWork() + { + // "5 million" should still be handled by quantity words, not units + CalculationResult result = await _service.EvaluateExpressionsAsync("5 million"); + + Assert.Single(result.OutputNumbers); + Assert.Equal(5_000_000, result.OutputNumbers[0], 0); + } + + [Fact] + public async Task DateTimeMath_TakesPriority() + { + // Date math should still work as before — "today + 5 days" is not a unit expression + CalculationResult result = await _service.EvaluateExpressionsAsync("today + 5 days"); + + // Should produce a date, not a unit result + Assert.Equal(0, result.ErrorCount); + Assert.DoesNotContain("days", result.Output.ToLowerInvariant().Split('\n')[0].Split("days")[0]); + } + + [Fact] + public async Task PlainNumbersStillWork() + { + // Regular math should be unaffected + CalculationResult result = await _service.EvaluateExpressionsAsync("2 + 3"); + + Assert.Single(result.OutputNumbers); + Assert.Equal(5, result.OutputNumbers[0], 1); + } + + [Fact] + public async Task MultipleConversions_TracksOutputNumbers() + { + string input = "5 miles to km\n10 kg to pounds\n100 celsius to fahrenheit"; + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Equal(3, result.OutputNumbers.Count); + Assert.Equal(0, result.ErrorCount); + } + + [Fact] + public async Task DominantUnit_SetCorrectly() + { + string input = "5 km\n+ 3 km\n+ 2 km"; + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Equal("km", result.DominantUnit); + } + + [Fact] + public async Task DominantUnit_NullForPlainMath() + { + CalculationResult result = await _service.EvaluateExpressionsAsync("2 + 3"); + + Assert.Null(result.DominantUnit); + } + + #endregion Ambiguity & Edge Case Tests + + #region TryEvaluateUnitConversion Direct Tests + + [Theory] + [InlineData("5 miles to km", true)] + [InlineData("100 fahrenheit to celsius", true)] + [InlineData("2 + 3", false)] + [InlineData("hello world", false)] + [InlineData("x = 10", false)] + [InlineData("", false)] + public void TryEvaluateUnitConversion_DetectsCorrectly(string input, bool expected) + { + bool result = _service.TryEvaluateUnitConversion( + input, out _, out _, null); + + Assert.Equal(expected, result); + } + + [Fact] + public void TryEvaluateUnitConversion_ContinuationWithoutPrevious_ReturnsFalse() + { + // "to km" without a previous unit result should not match + bool result = _service.TryEvaluateUnitConversion( + "to km", out _, out _, null); + + Assert.False(result); + } + + [Fact] + public void TryEvaluateUnitConversion_ContinuationWithPrevious_ReturnsTrue() + { + var previous = new CalculationService.UnitResult + { + Value = 5, + Unit = UnitsNet.Units.LengthUnit.Mile, + QuantityName = "Length", + Abbreviation = "mi" + }; + + bool result = _service.TryEvaluateUnitConversion( + "to km", out string output, out _, previous); + + Assert.True(result); + Assert.Contains("km", output); + } + + #endregion TryEvaluateUnitConversion Direct Tests + + #region Plural & Alias Tests + + [Theory] + [InlineData("1 meter to feet")] + [InlineData("1 meters to feet")] + [InlineData("1 m to ft")] + [InlineData("1 foot to meters")] + [InlineData("1 feet to meters")] + [InlineData("1 ft to m")] + public async Task UnitAliases_AllResolveCorrectly(string input) + { + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Equal(0, result.ErrorCount); + Assert.Single(result.OutputNumbers); + } + + [Theory] + [InlineData("1 liter to mL")] + [InlineData("1 litre to mL")] + [InlineData("1 liters to mL")] + [InlineData("1 litres to mL")] + public async Task BritishSpellings_Work(string input) + { + CalculationResult result = await _service.EvaluateExpressionsAsync(input); + + Assert.Equal(0, result.ErrorCount); + Assert.Single(result.OutputNumbers); + Assert.Equal(1000, result.OutputNumbers[0], 1); + } + + #endregion Plural & Alias Tests +} diff --git a/Text-Grab/Services/CalculationService.UnitMath.cs b/Text-Grab/Services/CalculationService.UnitMath.cs new file mode 100644 index 00000000..6c2d65f0 --- /dev/null +++ b/Text-Grab/Services/CalculationService.UnitMath.cs @@ -0,0 +1,602 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using UnitsNet; +using UnitsNet.Units; + +namespace Text_Grab.Services; + +public partial class CalculationService +{ + /// + /// Stores information about a resolved measurement unit, mapping to a UnitsNet enum value. + /// + private readonly record struct UnitInfo(Enum Unit, string QuantityName, string Abbreviation); + + /// + /// Represents the result of a unit-bearing evaluation for tracking across lines. + /// Used for operator continuation (e.g., "5 km" then "+ 3 km" or "to miles"). + /// + public class UnitResult + { + /// The numeric value in the current unit. + public double Value { get; set; } + + /// The UnitsNet unit enum value (e.g., LengthUnit.Kilometer). + public Enum Unit { get; set; } = default!; + + /// The quantity type name (e.g., "Length", "Mass"). + public string QuantityName { get; set; } = string.Empty; + + /// The display abbreviation (e.g., "km", "lb"). + public string Abbreviation { get; set; } = string.Empty; + } + + /// + /// Maps common unit names and abbreviations to UnitsNet enum values. + /// Keys are case-insensitive. Includes singular, plural, and abbreviation forms. + /// + private static readonly Dictionary _unitLookup = + new(StringComparer.OrdinalIgnoreCase) + { + // ══════════════════════════════════════════════════════════════ + // LENGTH + // ══════════════════════════════════════════════════════════════ + { "m", new(LengthUnit.Meter, "Length", "m") }, + { "meter", new(LengthUnit.Meter, "Length", "m") }, + { "meters", new(LengthUnit.Meter, "Length", "m") }, + { "cm", new(LengthUnit.Centimeter, "Length", "cm") }, + { "centimeter", new(LengthUnit.Centimeter, "Length", "cm") }, + { "centimeters", new(LengthUnit.Centimeter, "Length", "cm") }, + { "mm", new(LengthUnit.Millimeter, "Length", "mm") }, + { "millimeter", new(LengthUnit.Millimeter, "Length", "mm") }, + { "millimeters", new(LengthUnit.Millimeter, "Length", "mm") }, + { "km", new(LengthUnit.Kilometer, "Length", "km") }, + { "kilometer", new(LengthUnit.Kilometer, "Length", "km") }, + { "kilometers", new(LengthUnit.Kilometer, "Length", "km") }, + { "in", new(LengthUnit.Inch, "Length", "in") }, + { "inch", new(LengthUnit.Inch, "Length", "in") }, + { "inches", new(LengthUnit.Inch, "Length", "in") }, + { "ft", new(LengthUnit.Foot, "Length", "ft") }, + { "foot", new(LengthUnit.Foot, "Length", "ft") }, + { "feet", new(LengthUnit.Foot, "Length", "ft") }, + { "yd", new(LengthUnit.Yard, "Length", "yd") }, + { "yard", new(LengthUnit.Yard, "Length", "yd") }, + { "yards", new(LengthUnit.Yard, "Length", "yd") }, + { "mi", new(LengthUnit.Mile, "Length", "mi") }, + { "mile", new(LengthUnit.Mile, "Length", "mi") }, + { "miles", new(LengthUnit.Mile, "Length", "mi") }, + { "nmi", new(LengthUnit.NauticalMile, "Length", "nmi") }, + { "nautical mile", new(LengthUnit.NauticalMile, "Length", "nmi") }, + { "nautical miles", new(LengthUnit.NauticalMile, "Length", "nmi") }, + + // ══════════════════════════════════════════════════════════════ + // MASS + // ══════════════════════════════════════════════════════════════ + { "g", new(MassUnit.Gram, "Mass", "g") }, + { "gram", new(MassUnit.Gram, "Mass", "g") }, + { "grams", new(MassUnit.Gram, "Mass", "g") }, + { "kg", new(MassUnit.Kilogram, "Mass", "kg") }, + { "kilogram", new(MassUnit.Kilogram, "Mass", "kg") }, + { "kilograms", new(MassUnit.Kilogram, "Mass", "kg") }, + { "mg", new(MassUnit.Milligram, "Mass", "mg") }, + { "milligram", new(MassUnit.Milligram, "Mass", "mg") }, + { "milligrams", new(MassUnit.Milligram, "Mass", "mg") }, + { "lb", new(MassUnit.Pound, "Mass", "lb") }, + { "lbs", new(MassUnit.Pound, "Mass", "lb") }, + { "pound", new(MassUnit.Pound, "Mass", "lb") }, + { "pounds", new(MassUnit.Pound, "Mass", "lb") }, + { "oz", new(MassUnit.Ounce, "Mass", "oz") }, + { "ounce", new(MassUnit.Ounce, "Mass", "oz") }, + { "ounces", new(MassUnit.Ounce, "Mass", "oz") }, + { "ton", new(MassUnit.ShortTon, "Mass", "short tn") }, + { "tons", new(MassUnit.ShortTon, "Mass", "short tn") }, + { "short ton", new(MassUnit.ShortTon, "Mass", "short tn") }, + { "short tons", new(MassUnit.ShortTon, "Mass", "short tn") }, + { "tonne", new(MassUnit.Tonne, "Mass", "t") }, + { "tonnes", new(MassUnit.Tonne, "Mass", "t") }, + { "metric ton", new(MassUnit.Tonne, "Mass", "t") }, + { "metric tons", new(MassUnit.Tonne, "Mass", "t") }, + { "st", new(MassUnit.Stone, "Mass", "st") }, + { "stone", new(MassUnit.Stone, "Mass", "st") }, + { "stones", new(MassUnit.Stone, "Mass", "st") }, + + // ══════════════════════════════════════════════════════════════ + // TEMPERATURE + // ══════════════════════════════════════════════════════════════ + { "celsius", new(TemperatureUnit.DegreeCelsius, "Temperature", "°C") }, + { "°C", new(TemperatureUnit.DegreeCelsius, "Temperature", "°C") }, + { "degC", new(TemperatureUnit.DegreeCelsius, "Temperature", "°C") }, + { "fahrenheit", new(TemperatureUnit.DegreeFahrenheit, "Temperature", "°F") }, + { "°F", new(TemperatureUnit.DegreeFahrenheit, "Temperature", "°F") }, + { "degF", new(TemperatureUnit.DegreeFahrenheit, "Temperature", "°F") }, + { "kelvin", new(TemperatureUnit.Kelvin, "Temperature", "K") }, + + // ══════════════════════════════════════════════════════════════ + // VOLUME + // ══════════════════════════════════════════════════════════════ + { "liter", new(VolumeUnit.Liter, "Volume", "L") }, + { "liters", new(VolumeUnit.Liter, "Volume", "L") }, + { "litre", new(VolumeUnit.Liter, "Volume", "L") }, + { "litres", new(VolumeUnit.Liter, "Volume", "L") }, + { "L", new(VolumeUnit.Liter, "Volume", "L") }, + { "mL", new(VolumeUnit.Milliliter, "Volume", "mL") }, + { "milliliter", new(VolumeUnit.Milliliter, "Volume", "mL") }, + { "milliliters", new(VolumeUnit.Milliliter, "Volume", "mL") }, + { "millilitre", new(VolumeUnit.Milliliter, "Volume", "mL") }, + { "millilitres", new(VolumeUnit.Milliliter, "Volume", "mL") }, + { "gal", new(VolumeUnit.UsGallon, "Volume", "gal") }, + { "gallon", new(VolumeUnit.UsGallon, "Volume", "gal") }, + { "gallons", new(VolumeUnit.UsGallon, "Volume", "gal") }, + { "qt", new(VolumeUnit.UsQuart, "Volume", "qt") }, + { "quart", new(VolumeUnit.UsQuart, "Volume", "qt") }, + { "quarts", new(VolumeUnit.UsQuart, "Volume", "qt") }, + { "pt", new(VolumeUnit.UsPint, "Volume", "pt") }, + { "pint", new(VolumeUnit.UsPint, "Volume", "pt") }, + { "pints", new(VolumeUnit.UsPint, "Volume", "pt") }, + { "cup", new(VolumeUnit.UsCustomaryCup, "Volume", "cup") }, + { "cups", new(VolumeUnit.UsCustomaryCup, "Volume", "cup") }, + { "fl oz", new(VolumeUnit.UsOunce, "Volume", "fl oz") }, + { "floz", new(VolumeUnit.UsOunce, "Volume", "fl oz") }, + { "fluid ounce", new(VolumeUnit.UsOunce, "Volume", "fl oz") }, + { "fluid ounces", new(VolumeUnit.UsOunce, "Volume", "fl oz") }, + { "tbsp", new(VolumeUnit.UsTablespoon, "Volume", "tbsp") }, + { "tablespoon", new(VolumeUnit.UsTablespoon, "Volume", "tbsp") }, + { "tablespoons", new(VolumeUnit.UsTablespoon, "Volume", "tbsp") }, + { "tsp", new(VolumeUnit.UsTeaspoon, "Volume", "tsp") }, + { "teaspoon", new(VolumeUnit.UsTeaspoon, "Volume", "tsp") }, + { "teaspoons", new(VolumeUnit.UsTeaspoon, "Volume", "tsp") }, + + // ══════════════════════════════════════════════════════════════ + // SPEED + // ══════════════════════════════════════════════════════════════ + { "mph", new(SpeedUnit.MilePerHour, "Speed", "mph") }, + { "miles per hour", new(SpeedUnit.MilePerHour, "Speed", "mph") }, + { "km/h", new(SpeedUnit.KilometerPerHour, "Speed", "km/h") }, + { "kph", new(SpeedUnit.KilometerPerHour, "Speed", "km/h") }, + { "kilometers per hour", new(SpeedUnit.KilometerPerHour, "Speed", "km/h") }, + { "m/s", new(SpeedUnit.MeterPerSecond, "Speed", "m/s") }, + { "meters per second", new(SpeedUnit.MeterPerSecond, "Speed", "m/s") }, + { "knot", new(SpeedUnit.Knot, "Speed", "kn") }, + { "knots", new(SpeedUnit.Knot, "Speed", "kn") }, + { "kn", new(SpeedUnit.Knot, "Speed", "kn") }, + + // ══════════════════════════════════════════════════════════════ + // AREA + // ══════════════════════════════════════════════════════════════ + { "m²", new(AreaUnit.SquareMeter, "Area", "m²") }, + { "sq m", new(AreaUnit.SquareMeter, "Area", "m²") }, + { "square meter", new(AreaUnit.SquareMeter, "Area", "m²") }, + { "square meters", new(AreaUnit.SquareMeter, "Area", "m²") }, + { "km²", new(AreaUnit.SquareKilometer, "Area", "km²") }, + { "sq km", new(AreaUnit.SquareKilometer, "Area", "km²") }, + { "square kilometer", new(AreaUnit.SquareKilometer, "Area", "km²") }, + { "square kilometers", new(AreaUnit.SquareKilometer, "Area", "km²") }, + { "ft²", new(AreaUnit.SquareFoot, "Area", "ft²") }, + { "sq ft", new(AreaUnit.SquareFoot, "Area", "ft²") }, + { "square foot", new(AreaUnit.SquareFoot, "Area", "ft²") }, + { "square feet", new(AreaUnit.SquareFoot, "Area", "ft²") }, + { "mi²", new(AreaUnit.SquareMile, "Area", "mi²") }, + { "sq mi", new(AreaUnit.SquareMile, "Area", "mi²") }, + { "square mile", new(AreaUnit.SquareMile, "Area", "mi²") }, + { "square miles", new(AreaUnit.SquareMile, "Area", "mi²") }, + { "in²", new(AreaUnit.SquareInch, "Area", "in²") }, + { "sq in", new(AreaUnit.SquareInch, "Area", "in²") }, + { "square inch", new(AreaUnit.SquareInch, "Area", "in²") }, + { "square inches", new(AreaUnit.SquareInch, "Area", "in²") }, + { "yd²", new(AreaUnit.SquareYard, "Area", "yd²") }, + { "sq yd", new(AreaUnit.SquareYard, "Area", "yd²") }, + { "square yard", new(AreaUnit.SquareYard, "Area", "yd²") }, + { "square yards", new(AreaUnit.SquareYard, "Area", "yd²") }, + { "cm²", new(AreaUnit.SquareCentimeter, "Area", "cm²") }, + { "sq cm", new(AreaUnit.SquareCentimeter, "Area", "cm²") }, + { "acre", new(AreaUnit.Acre, "Area", "ac") }, + { "acres", new(AreaUnit.Acre, "Area", "ac") }, + { "ac", new(AreaUnit.Acre, "Area", "ac") }, + { "hectare", new(AreaUnit.Hectare, "Area", "ha") }, + { "hectares", new(AreaUnit.Hectare, "Area", "ha") }, + { "ha", new(AreaUnit.Hectare, "Area", "ha") }, + }; + + /// + /// Attempts to evaluate a line as a unit conversion or unit-bearing expression. + /// Supports patterns: + /// + /// Explicit conversion: "5 miles to km", "100°F in celsius" + /// Continuation conversion: "to km" (from previous unit result) + /// Operator with units: "+ 3 km", "- 5 miles" (from previous unit result) + /// Scale operator: "* 2", "/ 3" (from previous unit result, preserves unit) + /// Standalone unit: "5 meters" (tracked for future continuation) + /// + /// + /// The input line to evaluate + /// The formatted display result if successful + /// The parsed unit result for tracking across lines + /// The previous line's unit result for continuation + /// True if the line was successfully evaluated as a unit expression + public bool TryEvaluateUnitConversion( + string line, + out string result, + out UnitResult? unitResult, + UnitResult? previousUnitResult) + { + result = string.Empty; + unitResult = null; + + if (string.IsNullOrWhiteSpace(line)) + return false; + + string trimmed = line.Trim(); + + // 1. Continuation conversion: "to km" / "in feet" (requires previous unit result) + if (previousUnitResult is not null + && TryContinuationConversion(trimmed, previousUnitResult, out result, out unitResult)) + return true; + + // 2. Operator with unit: "+ 3 km", "- 5 miles" (requires previous unit result) + if (previousUnitResult is not null + && TryOperatorWithUnit(trimmed, previousUnitResult, out result, out unitResult)) + return true; + + // 3. Scale operator: "* 2", "/ 3" (requires previous unit result, preserves unit) + if (previousUnitResult is not null + && TryScaleOperator(trimmed, previousUnitResult, out result, out unitResult)) + return true; + + // 4. Explicit conversion: "5 miles to km", "100°F in celsius" + if (TryExplicitConversion(trimmed, out result, out unitResult)) + return true; + + // 5. Standalone unit: "5 meters" (track for future continuation) + if (TryStandaloneUnit(trimmed, out result, out unitResult)) + return true; + + return false; + } + + #region Unit Conversion Helpers + + /// + /// Handles "to km" or "in feet" when there is a previous unit result to convert from. + /// + private bool TryContinuationConversion( + string trimmed, + UnitResult previous, + out string result, + out UnitResult? unitResult) + { + result = string.Empty; + unitResult = null; + + System.Text.RegularExpressions.Match match = ContinuationConversionPattern().Match(trimmed); + if (!match.Success) + return false; + + string targetStr = match.Groups[1].Value.Trim(); + if (!TryResolveUnit(targetStr, out UnitInfo target)) + return false; + + // Ensure compatible quantity types (e.g., both are Length) + if (previous.Unit.GetType() != target.Unit.GetType()) + return false; + + try + { + IQuantity source = Quantity.From(previous.Value, previous.Unit); + IQuantity converted = source.ToUnit(target.Unit); + double convertedValue = (double)converted.Value; + + unitResult = new UnitResult + { + Value = convertedValue, + Unit = target.Unit, + QuantityName = target.QuantityName, + Abbreviation = target.Abbreviation + }; + result = FormatUnitValue(convertedValue, target.Abbreviation); + return true; + } + catch + { + return false; + } + } + + /// + /// Handles "+ 3 km" or "- 5 miles" operator continuation with units. + /// Converts the operand to the previous unit before adding/subtracting. + /// + private bool TryOperatorWithUnit( + string trimmed, + UnitResult previous, + out string result, + out UnitResult? unitResult) + { + result = string.Empty; + unitResult = null; + + System.Text.RegularExpressions.Match match = OperatorWithUnitPattern().Match(trimmed); + if (!match.Success) + return false; + + string op = match.Groups["op"].Value; + string numberStr = match.Groups["number"].Value; + string unitStr = match.Groups["unit"].Value.Trim(); + + if (!double.TryParse(numberStr, NumberStyles.Any, CultureInfo.InvariantCulture, out double number)) + return false; + + if (!TryResolveUnit(unitStr, out UnitInfo operandUnit)) + return false; + + // Must be same quantity type + if (previous.Unit.GetType() != operandUnit.Unit.GetType()) + return false; + + try + { + // Convert operand to the previous result's unit + double operandInPreviousUnit; + if (operandUnit.Unit.Equals(previous.Unit)) + { + operandInPreviousUnit = number; + } + else + { + IQuantity operandQuantity = Quantity.From(number, operandUnit.Unit); + IQuantity converted = operandQuantity.ToUnit(previous.Unit); + operandInPreviousUnit = (double)converted.Value; + } + + double newValue = op == "+" + ? previous.Value + operandInPreviousUnit + : previous.Value - operandInPreviousUnit; + + unitResult = new UnitResult + { + Value = newValue, + Unit = previous.Unit, + QuantityName = previous.QuantityName, + Abbreviation = previous.Abbreviation + }; + result = FormatUnitValue(newValue, previous.Abbreviation); + return true; + } + catch + { + return false; + } + } + + /// + /// Handles "* 2" or "/ 3" scaling operators that preserve the previous unit. + /// + private bool TryScaleOperator( + string trimmed, + UnitResult previous, + out string result, + out UnitResult? unitResult) + { + result = string.Empty; + unitResult = null; + + System.Text.RegularExpressions.Match match = ScaleOperatorPattern().Match(trimmed); + if (!match.Success) + return false; + + string op = match.Groups["op"].Value; + string numberStr = match.Groups["number"].Value; + + if (!double.TryParse(numberStr, NumberStyles.Any, CultureInfo.InvariantCulture, out double number)) + return false; + + if (op == "/" && number == 0) + return false; // Avoid division by zero + + double newValue = op == "*" + ? previous.Value * number + : previous.Value / number; + + unitResult = new UnitResult + { + Value = newValue, + Unit = previous.Unit, + QuantityName = previous.QuantityName, + Abbreviation = previous.Abbreviation + }; + result = FormatUnitValue(newValue, previous.Abbreviation); + return true; + } + + /// + /// Handles explicit "5 miles to km" or "100°F in celsius" conversion expressions. + /// Tries the "to" keyword first (unambiguous), then "in" as fallback. + /// + private bool TryExplicitConversion( + string trimmed, + out string result, + out UnitResult? unitResult) + { + result = string.Empty; + unitResult = null; + + // Try "to" keyword first (unambiguous, avoids conflict with "in" as inches) + System.Text.RegularExpressions.Match match = ToConversionPattern().Match(trimmed); + if (!match.Success) + { + // Fallback to "in" keyword + match = InConversionPattern().Match(trimmed); + } + + if (!match.Success) + return false; + + string sourcePart = match.Groups[1].Value.Trim(); + string targetStr = match.Groups[2].Value.Trim(); + + if (!TryExtractValueAndUnit(sourcePart, out double value, out UnitInfo sourceUnit)) + return false; + + if (!TryResolveUnit(targetStr, out UnitInfo targetUnit)) + return false; + + // Ensure compatible quantity types + if (sourceUnit.Unit.GetType() != targetUnit.Unit.GetType()) + return false; + + try + { + IQuantity source = Quantity.From(value, sourceUnit.Unit); + IQuantity converted = source.ToUnit(targetUnit.Unit); + double convertedValue = (double)converted.Value; + + unitResult = new UnitResult + { + Value = convertedValue, + Unit = targetUnit.Unit, + QuantityName = targetUnit.QuantityName, + Abbreviation = targetUnit.Abbreviation + }; + result = FormatUnitValue(convertedValue, targetUnit.Abbreviation); + return true; + } + catch + { + return false; + } + } + + /// + /// Handles standalone unit expressions like "5 meters" — tracks the unit for future + /// continuation but does not convert. Requires multi-character unit abbreviations + /// to avoid conflicts with single-letter variable names and quantity words. + /// + private bool TryStandaloneUnit( + string trimmed, + out string result, + out UnitResult? unitResult) + { + result = string.Empty; + unitResult = null; + + if (!TryExtractValueAndUnit(trimmed, out double value, out UnitInfo unit)) + return false; + + // For standalone detection, skip single-char unit text that could conflict + // with variable names or quantity words (e.g., "5 m" where m could be a variable). + // Multi-char input like "meters" is unambiguous even if the abbreviation is "m". + // Single-char units still work in explicit conversions with "to"/"in" keyword. + System.Text.RegularExpressions.Match unitMatch = NumberWithUnitPattern().Match(trimmed); + string inputUnitText = unitMatch.Success ? unitMatch.Groups["unit"].Value.Trim() : string.Empty; + if (inputUnitText.Length <= 1) + return false; + + // Don't capture if the unit text matches a defined variable name + string[] words = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (words.Length >= 2) + { + string unitText = string.Join(" ", words[1..]); + if (_parameters.ContainsKey(unitText)) + return false; + } + + unitResult = new UnitResult + { + Value = value, + Unit = unit.Unit, + QuantityName = unit.QuantityName, + Abbreviation = unit.Abbreviation + }; + result = FormatUnitValue(value, unit.Abbreviation); + return true; + } + + /// + /// Extracts a numeric value and unit from a string like "5 miles", "100°F", "3.5 gallons". + /// The number must appear at the beginning and the unit at the end. + /// + private static bool TryExtractValueAndUnit(string input, out double value, out UnitInfo unitInfo) + { + value = 0; + unitInfo = default; + + if (string.IsNullOrWhiteSpace(input)) + return false; + + System.Text.RegularExpressions.Match match = NumberWithUnitPattern().Match(input.Trim()); + if (!match.Success) + return false; + + string numberStr = match.Groups["number"].Value; + string unitStr = match.Groups["unit"].Value.Trim(); + + if (!double.TryParse(numberStr, NumberStyles.Any, CultureInfo.InvariantCulture, out value)) + return false; + + return TryResolveUnit(unitStr, out unitInfo); + } + + /// + /// Looks up a unit string in the unit dictionary. + /// + private static bool TryResolveUnit(string unitString, out UnitInfo entry) + { + return _unitLookup.TryGetValue(unitString.Trim(), out entry); + } + + /// + /// Formats a numeric value with a unit abbreviation for display. + /// Uses the standard FormatResult for the number portion. + /// + private string FormatUnitValue(double value, string abbreviation) + { + string formatted = FormatResult(value); + return $"{formatted} {abbreviation}"; + } + + #endregion Unit Conversion Helpers + + #region Unit Math Regex Patterns + + /// + /// Matches "to km" or "in feet" — continuation conversion from previous unit result. + /// + [System.Text.RegularExpressions.GeneratedRegex(@"^(?:to|in)\s+(.+)$", System.Text.RegularExpressions.RegexOptions.IgnoreCase)] + private static partial System.Text.RegularExpressions.Regex ContinuationConversionPattern(); + + /// + /// Matches "5 miles to km" — explicit conversion with "to" keyword. + /// Uses lazy matching on the source to prefer the earliest split point. + /// + [System.Text.RegularExpressions.GeneratedRegex(@"^(.+?)\s+to\s+(.+)$", System.Text.RegularExpressions.RegexOptions.IgnoreCase)] + private static partial System.Text.RegularExpressions.Regex ToConversionPattern(); + + /// + /// Matches "5 gallons in liters" — explicit conversion with "in" keyword. + /// Used as fallback after "to" pattern to avoid conflicts with "in" as inches. + /// + [System.Text.RegularExpressions.GeneratedRegex(@"^(.+?)\s+in\s+(.+)$", System.Text.RegularExpressions.RegexOptions.IgnoreCase)] + private static partial System.Text.RegularExpressions.Regex InConversionPattern(); + + /// + /// Matches a number followed by a unit: "5 miles", "100°F", "3.5 gallons". + /// + [System.Text.RegularExpressions.GeneratedRegex(@"^(?-?\d+\.?\d*)\s*(?.+)$")] + private static partial System.Text.RegularExpressions.Regex NumberWithUnitPattern(); + + /// + /// Matches operator continuation with unit: "+ 3 km", "- 5 miles". + /// Requires whitespace between operator, number, and unit. + /// + [System.Text.RegularExpressions.GeneratedRegex(@"^(?[+-])\s+(?\d+\.?\d*)\s+(?.+)$", System.Text.RegularExpressions.RegexOptions.IgnoreCase)] + private static partial System.Text.RegularExpressions.Regex OperatorWithUnitPattern(); + + /// + /// Matches scale operators without units: "* 2", "/ 3", "* 0.5". + /// Only used when there is a previous unit result to preserve. + /// + [System.Text.RegularExpressions.GeneratedRegex(@"^(?[*/])\s*(?\d+\.?\d*)$")] + private static partial System.Text.RegularExpressions.Regex ScaleOperatorPattern(); + + #endregion Unit Math Regex Patterns +} diff --git a/Text-Grab/Services/CalculationService.cs b/Text-Grab/Services/CalculationService.cs index d3a5a70f..10001f9b 100644 --- a/Text-Grab/Services/CalculationService.cs +++ b/Text-Grab/Services/CalculationService.cs @@ -48,6 +48,8 @@ public async Task EvaluateExpressionsAsync(string input) int errorCount = 0; double? previousLineResult = null; DateTime? previousDateTimeResult = null; + UnitResult? previousUnitResult = null; + Dictionary unitCounts = []; // Clear parameters and rebuild from scratch for each evaluation _parameters.Clear(); @@ -62,8 +64,9 @@ public async Task EvaluateExpressionsAsync(string input) } // If the line starts with a binary operator and we have a previous numeric result, - // prepend the previous result to form a complete expression - if (previousLineResult.HasValue && !previousDateTimeResult.HasValue && StartsWithBinaryOperator(trimmedLine)) + // prepend the previous result to form a complete expression. + // Skip when we have a previous unit result — unit continuation is handled separately. + if (previousLineResult.HasValue && !previousDateTimeResult.HasValue && previousUnitResult is null && StartsWithBinaryOperator(trimmedLine)) { string previousValueStr = previousLineResult.Value.ToString(CultureInfo.InvariantCulture); trimmedLine = previousValueStr + " " + trimmedLine; @@ -76,12 +79,34 @@ public async Task EvaluateExpressionsAsync(string input) results.Add(dateTimeResult); previousDateTimeResult = parsedDateTime; previousLineResult = null; + previousUnitResult = null; + } + else if (TryEvaluateUnitConversion(trimmedLine, out string unitResultStr, out UnitResult? newUnitResult, previousUnitResult)) + { + results.Add(unitResultStr); + previousUnitResult = newUnitResult; + previousDateTimeResult = null; + + if (newUnitResult is not null) + { + outputNumbers.Add(newUnitResult.Value); + previousLineResult = newUnitResult.Value; + + // Track unit frequency for DominantUnit + string unitKey = newUnitResult.Abbreviation; + unitCounts[unitKey] = unitCounts.GetValueOrDefault(unitKey) + 1; + } + else + { + previousLineResult = null; + } } else if (IsParameterAssignment(trimmedLine)) { string resultLine = await HandleParameterAssignmentAsync(trimmedLine); results.Add(resultLine); previousDateTimeResult = null; + previousUnitResult = null; // Extract variable name and add its value to output numbers int equalIndex = trimmedLine.IndexOf('='); @@ -110,6 +135,7 @@ public async Task EvaluateExpressionsAsync(string input) string resultLine = await EvaluateStandardExpressionAsync(trimmedLine); results.Add(resultLine); previousDateTimeResult = null; + previousUnitResult = null; // Try to parse the result as a number and add to output numbers // Remove formatting characters before parsing @@ -138,14 +164,21 @@ public async Task EvaluateExpressionsAsync(string input) errorCount++; previousLineResult = null; previousDateTimeResult = null; + previousUnitResult = null; } } + // Determine the most common unit across results + string? dominantUnit = unitCounts.Count > 0 + ? unitCounts.MaxBy(kv => kv.Value).Key + : null; + return new CalculationResult { Output = string.Join("\n", results), ErrorCount = errorCount, - OutputNumbers = outputNumbers + OutputNumbers = outputNumbers, + DominantUnit = dominantUnit }; } @@ -630,4 +663,11 @@ public class CalculationResult /// Includes both direct expression results and variable assignment values. /// public List OutputNumbers { get; set; } = []; + + /// + /// Gets or sets the most common unit abbreviation across unit-bearing results. + /// Null when no unit conversions were evaluated. Can be used to annotate + /// aggregate displays (e.g., "Sum: 45.2 kg"). + /// + public string? DominantUnit { get; set; } } diff --git a/Text-Grab/Text-Grab.csproj b/Text-Grab/Text-Grab.csproj index 310668fe..0fe29466 100644 --- a/Text-Grab/Text-Grab.csproj +++ b/Text-Grab/Text-Grab.csproj @@ -63,6 +63,7 @@ + diff --git a/Text-Grab/Views/EditTextWindow.xaml b/Text-Grab/Views/EditTextWindow.xaml index 1d7426b4..e42947e9 100644 --- a/Text-Grab/Views/EditTextWindow.xaml +++ b/Text-Grab/Views/EditTextWindow.xaml @@ -24,124 +24,130 @@ WindowStartupLocation="CenterScreen" mc:Ignorable="d"> - - + - - - - - - + + + + + + - + InputGestureText="Ctrl + Shift + V"/> + + InputGestureText="Ctrl + I"/> + InputGestureText="Ctrl + L"/> + InputGestureText="Ctrl + A"/> - - + InputGestureText="Shift + F3"/> + + + InputGestureText="Alt + Up"/> + InputGestureText="Alt + Down"/> + Executed="SplitOnSelectionCmdExecuted"/> + Executed="SplitAfterSelectionCmdExecuted"/> + Executed="IsolateSelectionCmdExecuted"/> + Executed="SingleLineCmdExecuted"/> + Executed="ToggleCase"/> + Executed="ReplaceReservedCharsCmdExecuted"/> + Executed="UnstackExecuted"/> + Executed="UnstackGroupExecuted"/> + Executed="DeleteAllSelectionExecuted"/> + Executed="DeleteAllSelectionPatternExecuted"/> + Executed="InsertSelectionOnEveryLine"/> + Executed="PasteExecuted"/> + Executed="LaunchUriExecuted"/> + Executed="MakeQrCodeExecuted"/> + Executed="WebSearchExecuted"/> + Executed="DefaultWebSearchExecuted"/> - - - - + + + + + Icon="{StaticResource TextGrabIcon}"/> - - + + - + - - + InputGestureText="Ctrl + O"/> + + + InputGestureText="Ctrl + S"/> + InputGestureText="Ctrl + Shift + S"/> + Header="_Copy And Close"/> - + Header="Close and I_nsert"/> + + Header="Text Grab Sett_ings..."/> + InputGestureText="Alt + F4"/> - - - - - - + + + + + + - + InputGestureText="Ctrl + Shift + V"/> + + Header="Launch URL"/> + Header="Make _Single Line"/> + Header="_Trim Each Line"/> + Header="Try To Make _Numbers"/> + Header="Try To Make _Letters"/> + Header="Correct Common _GUID/UUID Errors"/> + InputGestureText="Shift + F3"/> + Header="Remove Duplicate Lines"/> + InputGestureText="Ctrl + R"/> + InputGestureText="Ctrl + U"/> + Header="_Unstack Text (Select First Column)"/> + Header="_Add, Remove, Limit..."/> + Header="Find and Replace"/> - - + Header="Default Web Search"/> + + + InputGestureText="Ctrl + W"/> + InputGestureText="Ctrl + L"/> + InputGestureText="Ctrl + A"/> + Header="Select None"/> - + Header="Delete Selected Text"/> + + InputGestureText="Alt + Up"/> - + InputGestureText="Alt + Down"/> + + Header="Split Lines _Before Each Selection"/> + Header="Split Lines _After Each Selection"/> + InputGestureText="Ctrl + I"/> + Header="Delete All Instances of Selection"/> + Header="Delete All Instances of Selection Simple Pattern"/> + Header="Insert Selection On Every Line"/> + Header="_Summarize Paragraph"/> + Header="_Rewrite"/> + Header="_Convert to Table"/> - + Header="Translate to System Language"/> + + Tag="English"/> + Tag="Spanish"/> + Tag="French"/> + Tag="German"/> + Tag="Italian"/> + Tag="Portuguese"/> + Tag="Russian"/> + Tag="Japanese"/> + Tag="Chinese (Simplified)"/> + Tag="Korean"/> + Tag="Arabic"/> + Tag="Hindi"/> + Header="E_xtract RegEx"/> + Header="_Learn About Local AI Features..."/> + IsCheckable="True"/> + Unchecked="MarginsMenuItem_Checked"/> + Unchecked="WrapTextCHBX_Checked"/> + Header="_Font..."/> + InputGestureText="Ctrl + F"/> + Header="Fullscreen with 2 second _delay"/> + Header="Grab previous region"/> - + InputGestureText="Ctrl + G"/> + + InputGestureText="Ctrl + Q"/> - + IsChecked="False"/> + - + Header="_List Files and Folders From Folder..."/> + - + Header="_Extract Text from Images in Folder..."/> + + StaysOpenOnClick="True"/> + StaysOpenOnClick="True"/> + StaysOpenOnClick="True"/> + StaysOpenOnClick="True"/> + StaysOpenOnClick="True"/> + IsChecked="False"/> + IsChecked="False"/> + IsChecked="False"/> + Unchecked="RestorePositionMenuItem_Checked"/> + Header="_Restore this windows's position"/> - + IsCheckable="True"/> + + Header="_New Window"/> + Header="New Window with Selected _Text"/> + Header="Make _QR Code"/> + Header="Edit _Last Grab"/> + Header="_Contact The Developer..."/> + Header="_Rate and Review..."/> + Header="_Feedback..."/> + Header="_About"/> - - + + + VerticalScrollBarVisibility="Auto"/> - - - - + + + + + IsCheckable="True"/> + IsCheckable="True"/> + IsCheckable="True"/> + IsCheckable="True"/> + IsCheckable="True"/> + IsCheckable="True"/> + IsCheckable="True"/> @@ -698,31 +715,31 @@ + IsCheckable="True"/> + IsCheckable="True"/> + IsCheckable="True"/> + IsCheckable="True"/> + IsCheckable="True"/> + IsCheckable="True"/> + IsCheckable="True"/> @@ -730,7 +747,7 @@ x:Name="CalcAggregateStatusText" FontSize="12" Foreground="{DynamicResource TextFillColorSecondaryBrush}" - Text="" /> + Text=""/> + Symbol="Copy24"/> @@ -758,7 +775,8 @@ BorderThickness="0" Click="CalcCopyAllButton_Click" ToolTip="Copy All Results"> - + @@ -791,40 +810,62 @@ Margin="0,0,0,8" FontSize="16" FontWeight="Bold" - Text="Calculation Pane" /> - - + Text="Calculation Pane"/> + + - - + Text="Features:"/> + + - - + + - - + + - - + + + + + + + + + Text="Examples:"/> - - - - - + + + + + + + + + + + TextWrapping="Wrap"/> - + - + @@ -860,7 +901,7 @@ Background="{DynamicResource SolidBackgroundFillColorBaseBrush}" MouseDoubleClick="TextBoxSplitter_MouseDoubleClick" ResizeDirection="Auto" - ShowsPreview="True" /> + ShowsPreview="True"/> - + - - + + @@ -893,13 +937,13 @@ Grid.Row="2" Background="Transparent"> - - + + + Orientation="Horizontal"/> + Text="0 matches"/> @@ -965,17 +1009,17 @@ + Text="Regex: "/> + Header="Save Pattern"/> + Header="Explain Pattern"/> @@ -993,12 +1037,12 @@ x:Name="CharDetailsButtonText" FontFamily="Cascadia Mono" FontSize="12" - Text="U+0000" /> + Text="U+0000"/> + Text="Ln 1, Col 0"/> - + - + @@ -1034,14 +1080,14 @@ x:Name="ProgressRing" Width="60" Height="60" - IsIndeterminate="True" /> + IsIndeterminate="True"/> + Text="Working..."/> From 1f7da7cd895df774816df5c02dad19279f1c9909 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 28 Feb 2026 10:24:38 -0600 Subject: [PATCH 019/109] Add support for "C" and "F" temp unit abbreviations Extend unit recognition to include "C" (Celsius) and "F" (Fahrenheit) abbreviations. Update unit conversion and detection tests to cover these cases. Add a check to return an empty string for empty expressions to prevent evaluation errors. --- Tests/UnitConversionTests.cs | 6 ++++++ Text-Grab/Services/CalculationService.UnitMath.cs | 2 ++ Text-Grab/Services/CalculationService.cs | 3 +++ 3 files changed, 11 insertions(+) diff --git a/Tests/UnitConversionTests.cs b/Tests/UnitConversionTests.cs index 90f8ef34..e8d116a0 100644 --- a/Tests/UnitConversionTests.cs +++ b/Tests/UnitConversionTests.cs @@ -33,6 +33,8 @@ public async Task ExplicitConversion_ContainsTargetUnit(string input, string exp [InlineData("1 kg to pounds", 2.205, 0.01)] [InlineData("100 fahrenheit to celsius", 37.778, 0.01)] [InlineData("0 celsius to fahrenheit", 32, 0.01)] + [InlineData("100 F to C", 37.778, 0.01)] + [InlineData("0 C to F", 32, 0.01)] [InlineData("1 foot to inches", 12, 0.01)] [InlineData("1 mile to feet", 5280, 1)] [InlineData("1 gallon to liters", 3.785, 0.01)] @@ -261,6 +263,8 @@ public async Task StandaloneUnit_CorrectNumericValue(string input, double expect [InlineData("100 celsius to fahrenheit", "°F")] [InlineData("212 fahrenheit to celsius", "°C")] [InlineData("0 celsius to kelvin", "K")] + [InlineData("100 C to F", "°F")] + [InlineData("212 F to C", "°C")] // Volume [InlineData("1 gallon to liters", "L")] [InlineData("1 cup to mL", "mL")] @@ -368,6 +372,8 @@ public async Task DominantUnit_NullForPlainMath() [Theory] [InlineData("5 miles to km", true)] [InlineData("100 fahrenheit to celsius", true)] + [InlineData("100 F to C", true)] + [InlineData("32 C to F", true)] [InlineData("2 + 3", false)] [InlineData("hello world", false)] [InlineData("x = 10", false)] diff --git a/Text-Grab/Services/CalculationService.UnitMath.cs b/Text-Grab/Services/CalculationService.UnitMath.cs index 6c2d65f0..50a4c010 100644 --- a/Text-Grab/Services/CalculationService.UnitMath.cs +++ b/Text-Grab/Services/CalculationService.UnitMath.cs @@ -107,9 +107,11 @@ public class UnitResult { "celsius", new(TemperatureUnit.DegreeCelsius, "Temperature", "°C") }, { "°C", new(TemperatureUnit.DegreeCelsius, "Temperature", "°C") }, { "degC", new(TemperatureUnit.DegreeCelsius, "Temperature", "°C") }, + { "C", new(TemperatureUnit.DegreeCelsius, "Temperature", "°C") }, { "fahrenheit", new(TemperatureUnit.DegreeFahrenheit, "Temperature", "°F") }, { "°F", new(TemperatureUnit.DegreeFahrenheit, "Temperature", "°F") }, { "degF", new(TemperatureUnit.DegreeFahrenheit, "Temperature", "°F") }, + { "F", new(TemperatureUnit.DegreeFahrenheit, "Temperature", "°F") }, { "kelvin", new(TemperatureUnit.Kelvin, "Temperature", "K") }, // ══════════════════════════════════════════════════════════════ diff --git a/Text-Grab/Services/CalculationService.cs b/Text-Grab/Services/CalculationService.cs index 10001f9b..6a457784 100644 --- a/Text-Grab/Services/CalculationService.cs +++ b/Text-Grab/Services/CalculationService.cs @@ -403,6 +403,9 @@ private async Task HandleParameterAssignmentAsync(string line) CultureInfo = CultureInfo ?? CultureInfo.CurrentCulture }; + if (string.IsNullOrEmpty(expr.ExpressionString)) + return string.Empty; + // Set up parameter handler for existing parameters expr.EvaluateParameterAsync += (name, args) => { From d8ed478ee937d76e5e056ac6cc46adb3066bd3d6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 16:36:51 +0000 Subject: [PATCH 020/109] Initial plan From a0fc61ed292f52e948e7ae8042f18e4758f6467f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 16:48:54 +0000 Subject: [PATCH 021/109] Apply all 5 PR review comment fixes Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com> --- Tests/CalculatorTests.cs | 180 ++++++++++-------- .../CalculationService.DateTimeMath.cs | 2 +- .../Services/CalculationService.UnitMath.cs | 67 ++++++- Text-Grab/Services/CalculationService.cs | 21 +- 4 files changed, 181 insertions(+), 89 deletions(-) diff --git a/Tests/CalculatorTests.cs b/Tests/CalculatorTests.cs index b40a1601..2490cff2 100644 --- a/Tests/CalculatorTests.cs +++ b/Tests/CalculatorTests.cs @@ -2353,75 +2353,81 @@ public async Task Percentage_WithSum_WorksCorrectly() #region DateTime Math Tests [Theory] - [InlineData("March 10, 2026 + 10 days", "3/20/2026")] - [InlineData("January 1, 2026 + 30 days", "1/31/2026")] - [InlineData("December 25, 2026 + 7 days", "1/1/2027")] - [InlineData("February 28, 2026 + 1 day", "3/1/2026")] - public async Task DateTimeMath_AddDays_ReturnsCorrectDate(string input, string expected) + [InlineData("March 10, 2026 + 10 days", 2026, 3, 20)] + [InlineData("January 1, 2026 + 30 days", 2026, 1, 31)] + [InlineData("December 25, 2026 + 7 days", 2027, 1, 1)] + [InlineData("February 28, 2026 + 1 day", 2026, 3, 1)] + public async Task DateTimeMath_AddDays_ReturnsCorrectDate(string input, int year, int month, int day) { CalculationService service = new(); CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expected = new DateTime(year, month, day).ToString("d", CultureInfo.CurrentCulture); Assert.Equal(expected, result.Output); Assert.Equal(0, result.ErrorCount); } [Theory] - [InlineData("January 1, 2026 + 2 weeks", "1/15/2026")] - [InlineData("March 1, 2026 + 1 week", "3/8/2026")] - [InlineData("March 14, 2026 - 2 weeks", "2/28/2026")] - public async Task DateTimeMath_AddWeeks_ReturnsCorrectDate(string input, string expected) + [InlineData("January 1, 2026 + 2 weeks", 2026, 1, 15)] + [InlineData("March 1, 2026 + 1 week", 2026, 3, 8)] + [InlineData("March 14, 2026 - 2 weeks", 2026, 2, 28)] + public async Task DateTimeMath_AddWeeks_ReturnsCorrectDate(string input, int year, int month, int day) { CalculationService service = new(); CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expected = new DateTime(year, month, day).ToString("d", CultureInfo.CurrentCulture); Assert.Equal(expected, result.Output); Assert.Equal(0, result.ErrorCount); } [Theory] - [InlineData("January 15, 2026 + 3 months", "4/15/2026")] - [InlineData("October 31, 2026 + 1 month", "11/30/2026")] - [InlineData("March 31, 2026 + 1 month", "4/30/2026")] - public async Task DateTimeMath_AddMonths_ReturnsCorrectDate(string input, string expected) + [InlineData("January 15, 2026 + 3 months", 2026, 4, 15)] + [InlineData("October 31, 2026 + 1 month", 2026, 11, 30)] + [InlineData("March 31, 2026 + 1 month", 2026, 4, 30)] + public async Task DateTimeMath_AddMonths_ReturnsCorrectDate(string input, int year, int month, int day) { CalculationService service = new(); CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expected = new DateTime(year, month, day).ToString("d", CultureInfo.CurrentCulture); Assert.Equal(expected, result.Output); Assert.Equal(0, result.ErrorCount); } [Theory] - [InlineData("January 1, 2020 + 5 years", "1/1/2025")] - [InlineData("February 29, 2024 + 1 year", "2/28/2025")] - [InlineData("June 15, 2026 + 10 years", "6/15/2036")] - public async Task DateTimeMath_AddYears_ReturnsCorrectDate(string input, string expected) + [InlineData("January 1, 2020 + 5 years", 2025, 1, 1)] + [InlineData("February 29, 2024 + 1 year", 2025, 2, 28)] + [InlineData("June 15, 2026 + 10 years", 2036, 6, 15)] + public async Task DateTimeMath_AddYears_ReturnsCorrectDate(string input, int year, int month, int day) { CalculationService service = new(); CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expected = new DateTime(year, month, day).ToString("d", CultureInfo.CurrentCulture); Assert.Equal(expected, result.Output); Assert.Equal(0, result.ErrorCount); } [Theory] - [InlineData("January 1, 2000 + 2 decades", "1/1/2020")] - [InlineData("June 15, 2010 + 1 decade", "6/15/2020")] - [InlineData("March 1, 2026 + 3 decades", "3/1/2056")] - public async Task DateTimeMath_AddDecades_ReturnsCorrectDate(string input, string expected) + [InlineData("January 1, 2000 + 2 decades", 2020, 1, 1)] + [InlineData("June 15, 2010 + 1 decade", 2020, 6, 15)] + [InlineData("March 1, 2026 + 3 decades", 2056, 3, 1)] + public async Task DateTimeMath_AddDecades_ReturnsCorrectDate(string input, int year, int month, int day) { CalculationService service = new(); CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expected = new DateTime(year, month, day).ToString("d", CultureInfo.CurrentCulture); Assert.Equal(expected, result.Output); Assert.Equal(0, result.ErrorCount); } [Theory] - [InlineData("March 20, 2026 - 10 days", "3/10/2026")] - [InlineData("January 5, 2026 - 10 days", "12/26/2025")] - [InlineData("March 1, 2026 - 1 month", "2/1/2026")] - [InlineData("January 1, 2026 - 1 year", "1/1/2025")] - public async Task DateTimeMath_Subtraction_ReturnsCorrectDate(string input, string expected) + [InlineData("March 20, 2026 - 10 days", 2026, 3, 10)] + [InlineData("January 5, 2026 - 10 days", 2025, 12, 26)] + [InlineData("March 1, 2026 - 1 month", 2026, 2, 1)] + [InlineData("January 1, 2026 - 1 year", 2025, 1, 1)] + public async Task DateTimeMath_Subtraction_ReturnsCorrectDate(string input, int year, int month, int day) { CalculationService service = new(); CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expected = new DateTime(year, month, day).ToString("d", CultureInfo.CurrentCulture); Assert.Equal(expected, result.Output); Assert.Equal(0, result.ErrorCount); } @@ -2432,20 +2438,22 @@ public async Task DateTimeMath_WithOrdinalSuffix_ParsesCorrectly() CalculationService service = new(); string input = "March 10th, 2026 + 10 days"; CalculationResult result = await service.EvaluateExpressionsAsync(input); - Assert.Equal("3/20/2026", result.Output); + string expected = new DateTime(2026, 3, 20).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); Assert.Equal(0, result.ErrorCount); } [Theory] - [InlineData("January 1st, 2026 + 5 days", "1/6/2026")] - [InlineData("February 2nd, 2026 + 5 days", "2/7/2026")] - [InlineData("March 3rd, 2026 + 5 days", "3/8/2026")] - [InlineData("April 4th, 2026 + 5 days", "4/9/2026")] - [InlineData("May 21st, 2026 + 1 day", "5/22/2026")] - public async Task DateTimeMath_VariousOrdinalSuffixes_ParseCorrectly(string input, string expected) + [InlineData("January 1st, 2026 + 5 days", 2026, 1, 6)] + [InlineData("February 2nd, 2026 + 5 days", 2026, 2, 7)] + [InlineData("March 3rd, 2026 + 5 days", 2026, 3, 8)] + [InlineData("April 4th, 2026 + 5 days", 2026, 4, 9)] + [InlineData("May 21st, 2026 + 1 day", 2026, 5, 22)] + public async Task DateTimeMath_VariousOrdinalSuffixes_ParseCorrectly(string input, int year, int month, int day) { CalculationService service = new(); CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expected = new DateTime(year, month, day).ToString("d", CultureInfo.CurrentCulture); Assert.Equal(expected, result.Output); Assert.Equal(0, result.ErrorCount); } @@ -2456,7 +2464,8 @@ public async Task DateTimeMath_NumericDateFormat_ParsesCorrectly() CalculationService service = new(); string input = "3/10/2026 + 10 days"; CalculationResult result = await service.EvaluateExpressionsAsync(input); - Assert.Equal("3/20/2026", result.Output); + string expected = new DateTime(2026, 3, 20).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); Assert.Equal(0, result.ErrorCount); } @@ -2466,7 +2475,8 @@ public async Task DateTimeMath_TwoDigitYear_ParsesCorrectly() CalculationService service = new(); string input = "3/10/26 + 10 days"; CalculationResult result = await service.EvaluateExpressionsAsync(input); - Assert.Equal("3/20/2026", result.Output); + string expected = new DateTime(2026, 3, 20).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); Assert.Equal(0, result.ErrorCount); } @@ -2476,7 +2486,8 @@ public async Task DateTimeMath_WithTimePM_ShowsTimeInResult() CalculationService service = new(); string input = "1/1/2026 2:00pm + 5 hours"; CalculationResult result = await service.EvaluateExpressionsAsync(input); - Assert.Contains("1/1/2026", result.Output); + string expectedDate = new DateTime(2026, 1, 1).ToString("d", CultureInfo.CurrentCulture); + Assert.Contains(expectedDate, result.Output); Assert.Contains("7:00pm", result.Output.ToLowerInvariant()); Assert.Equal(0, result.ErrorCount); } @@ -2487,7 +2498,8 @@ public async Task DateTimeMath_With24HrTime_ShowsTimeInResult() CalculationService service = new(); string input = "1/1/2026 14:30 + 2 hours"; CalculationResult result = await service.EvaluateExpressionsAsync(input); - Assert.Contains("1/1/2026", result.Output); + string expectedDate = new DateTime(2026, 1, 1).ToString("d", CultureInfo.CurrentCulture); + Assert.Contains(expectedDate, result.Output); Assert.Contains("4:30pm", result.Output.ToLowerInvariant()); Assert.Equal(0, result.ErrorCount); } @@ -2498,7 +2510,8 @@ public async Task DateTimeMath_MinutesAddition_CrossesDayBoundary() CalculationService service = new(); string input = "2/25/2026 11:02pm + 800 mins"; CalculationResult result = await service.EvaluateExpressionsAsync(input); - Assert.Contains("2/26/2026", result.Output); + string expectedDate = new DateTime(2026, 2, 26).ToString("d", CultureInfo.CurrentCulture); + Assert.Contains(expectedDate, result.Output); Assert.Contains("12:22pm", result.Output.ToLowerInvariant()); Assert.Equal(0, result.ErrorCount); } @@ -2509,7 +2522,8 @@ public async Task DateTimeMath_HoursAddition_CrossesDayBoundary() CalculationService service = new(); string input = "1/1/2026 10:00pm + 5 hours"; CalculationResult result = await service.EvaluateExpressionsAsync(input); - Assert.Contains("1/2/2026", result.Output); + string expectedDate = new DateTime(2026, 1, 2).ToString("d", CultureInfo.CurrentCulture); + Assert.Contains(expectedDate, result.Output); Assert.Contains("3:00am", result.Output.ToLowerInvariant()); Assert.Equal(0, result.ErrorCount); } @@ -2520,7 +2534,8 @@ public async Task DateTimeMath_MultipleOperations_WorkCorrectly() CalculationService service = new(); string input = "January 1, 2026 + 2 weeks + 3 days"; CalculationResult result = await service.EvaluateExpressionsAsync(input); - Assert.Equal("1/18/2026", result.Output); + string expected = new DateTime(2026, 1, 18).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); Assert.Equal(0, result.ErrorCount); } @@ -2530,7 +2545,8 @@ public async Task DateTimeMath_MixedAddSubtract_WorkCorrectly() CalculationService service = new(); string input = "March 1, 2026 + 1 month - 5 days"; CalculationResult result = await service.EvaluateExpressionsAsync(input); - Assert.Equal("3/27/2026", result.Output); + string expected = new DateTime(2026, 3, 27).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); Assert.Equal(0, result.ErrorCount); } @@ -2540,7 +2556,8 @@ public async Task DateTimeMath_MixedUnits_YearsAndMonths() CalculationService service = new(); string input = "January 1, 2026 + 1 year + 6 months"; CalculationResult result = await service.EvaluateExpressionsAsync(input); - Assert.Equal("7/1/2027", result.Output); + string expected = new DateTime(2027, 7, 1).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); Assert.Equal(0, result.ErrorCount); } @@ -2585,7 +2602,8 @@ public async Task DateTimeMath_FractionalDays_AssumesNoon() // Since result is midnight, only date is shown string input = "March 10, 2026 + 1.5 days"; CalculationResult result = await service.EvaluateExpressionsAsync(input); - Assert.Equal("3/12/2026", result.Output); + string expected = new DateTime(2026, 3, 12).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); Assert.Equal(0, result.ErrorCount); } @@ -2596,7 +2614,8 @@ public async Task DateTimeMath_FractionalDays_ShowsTimeWhenNonMidnight() // March 10 noon + 1.3 days = March 10 12:00 + 31.2 hours = March 11 19:12 string input = "March 10, 2026 + 1.3 days"; CalculationResult result = await service.EvaluateExpressionsAsync(input); - Assert.Contains("3/11/2026", result.Output); + string expectedDate = new DateTime(2026, 3, 11).ToString("d", CultureInfo.CurrentCulture); + Assert.Contains(expectedDate, result.Output); Assert.Contains("7:12pm", result.Output.ToLowerInvariant()); Assert.Equal(0, result.ErrorCount); } @@ -2608,7 +2627,8 @@ public async Task DateTimeMath_MinutesWithoutTime_ShowsTime() // No time specified, so base is midnight; adding minutes produces a time result string input = "January 1, 2026 + 90 minutes"; CalculationResult result = await service.EvaluateExpressionsAsync(input); - Assert.Contains("1/1/2026", result.Output); + string expectedDate = new DateTime(2026, 1, 1).ToString("d", CultureInfo.CurrentCulture); + Assert.Contains(expectedDate, result.Output); Assert.Contains("1:30am", result.Output.ToLowerInvariant()); Assert.Equal(0, result.ErrorCount); } @@ -2676,9 +2696,9 @@ public async Task DateTimeMath_WithMixedExpressions_WorksCorrectly() CalculationResult result = await service.EvaluateExpressionsAsync(input); string[] lines = result.Output.Split('\n'); Assert.Equal(3, lines.Length); - Assert.Equal("3/20/2026", lines[0]); + Assert.Equal(new DateTime(2026, 3, 20).ToString("d", CultureInfo.CurrentCulture), lines[0]); Assert.Equal("8", lines[1]); - Assert.Equal("1/1/2027", lines[2]); + Assert.Equal(new DateTime(2027, 1, 1).ToString("d", CultureInfo.CurrentCulture), lines[2]); Assert.Equal(0, result.ErrorCount); } @@ -2726,13 +2746,14 @@ public async Task DateTimeMath_NoDateStartOperator_UsesToday() } [Theory] - [InlineData("Jan 1, 2026 + 5 days", "1/6/2026")] - [InlineData("Feb 14, 2026 + 1 week", "2/21/2026")] - [InlineData("Dec 31, 2026 + 1 day", "1/1/2027")] - public async Task DateTimeMath_AbbreviatedMonths_ParseCorrectly(string input, string expected) + [InlineData("Jan 1, 2026 + 5 days", 2026, 1, 6)] + [InlineData("Feb 14, 2026 + 1 week", 2026, 2, 21)] + [InlineData("Dec 31, 2026 + 1 day", 2027, 1, 1)] + public async Task DateTimeMath_AbbreviatedMonths_ParseCorrectly(string input, int year, int month, int day) { CalculationService service = new(); CalculationResult result = await service.EvaluateExpressionsAsync(input); + string expected = new DateTime(year, month, day).ToString("d", CultureInfo.CurrentCulture); Assert.Equal(expected, result.Output); Assert.Equal(0, result.ErrorCount); } @@ -2744,7 +2765,8 @@ public async Task DateTimeMath_RealWorldExample_ProjectDeadline() string input = "June 1st, 2026 + 6 months + 2 weeks"; CalculationResult result = await service.EvaluateExpressionsAsync(input); // June 1 + 6 months = Dec 1, + 2 weeks = Dec 15 - Assert.Equal("12/15/2026", result.Output); + string expected = new DateTime(2026, 12, 15).ToString("d", CultureInfo.CurrentCulture); + Assert.Equal(expected, result.Output); Assert.Equal(0, result.ErrorCount); } @@ -2754,7 +2776,7 @@ public async Task DateTimeMath_RealWorldExample_MeetingTime() CalculationService service = new(); string input = "3/15/2026 9:00am + 90 mins"; CalculationResult result = await service.EvaluateExpressionsAsync(input); - Assert.Contains("3/15/2026", result.Output); + Assert.Contains(new DateTime(2026, 3, 15).ToString("d", CultureInfo.CurrentCulture), result.Output); Assert.Contains("10:30am", result.Output.ToLowerInvariant()); Assert.Equal(0, result.ErrorCount); } @@ -2766,7 +2788,7 @@ public async Task DateTimeMath_RealWorldExample_FlightArrival() // Flight departs 11pm, arrives after 14 hours string input = "7/4/2026 11:00pm + 14 hours"; CalculationResult result = await service.EvaluateExpressionsAsync(input); - Assert.Contains("7/5/2026", result.Output); + Assert.Contains(new DateTime(2026, 7, 5).ToString("d", CultureInfo.CurrentCulture), result.Output); Assert.Contains("1:00pm", result.Output.ToLowerInvariant()); Assert.Equal(0, result.ErrorCount); } @@ -2783,7 +2805,7 @@ public async Task DateTimeMath_CombinedSegments_WeeksDaysHours() // = Jan 1 + 35 days + 3 days + 8 hours = Feb 8 2026 8:00am string input = "January 1, 2026 + 5 weeks 3 days 8 hours"; CalculationResult result = await service.EvaluateExpressionsAsync(input); - Assert.Contains("2/8/2026", result.Output); + Assert.Contains(new DateTime(2026, 2, 8).ToString("d", CultureInfo.CurrentCulture), result.Output); Assert.Contains("8:00am", result.Output.ToLowerInvariant()); Assert.Equal(0, result.ErrorCount); } @@ -2807,7 +2829,7 @@ public async Task DateTimeMath_CombinedSegments_YearsMonths() // January 1, 2026 + 1 year 6 months = July 1, 2027 string input = "January 1, 2026 + 1 year 6 months"; CalculationResult result = await service.EvaluateExpressionsAsync(input); - Assert.Equal("7/1/2027", result.Output); + Assert.Equal(new DateTime(2027, 7, 1).ToString("d", CultureInfo.CurrentCulture), result.Output); Assert.Equal(0, result.ErrorCount); } @@ -2818,7 +2840,7 @@ public async Task DateTimeMath_CombinedSegments_WeeksDays() // March 10, 2026 + 2 weeks 3 days = March 10 + 14 + 3 = March 27 string input = "March 10, 2026 + 2 weeks 3 days"; CalculationResult result = await service.EvaluateExpressionsAsync(input); - Assert.Equal("3/27/2026", result.Output); + Assert.Equal(new DateTime(2026, 3, 27).ToString("d", CultureInfo.CurrentCulture), result.Output); Assert.Equal(0, result.ErrorCount); } @@ -2829,7 +2851,7 @@ public async Task DateTimeMath_CombinedSegments_HoursMinutes() // 1/1/2026 9:00am + 2 hours 30 mins = 11:30am string input = "1/1/2026 9:00am + 2 hours 30 mins"; CalculationResult result = await service.EvaluateExpressionsAsync(input); - Assert.Contains("1/1/2026", result.Output); + Assert.Contains(new DateTime(2026, 1, 1).ToString("d", CultureInfo.CurrentCulture), result.Output); Assert.Contains("11:30am", result.Output.ToLowerInvariant()); Assert.Equal(0, result.ErrorCount); } @@ -2842,7 +2864,7 @@ public async Task DateTimeMath_CombinedSegments_ThreeUnits() // = Feb 1 + 14 days + 3 days = Feb 18 string input = "January 1, 2026 + 1 month 2 weeks 3 days"; CalculationResult result = await service.EvaluateExpressionsAsync(input); - Assert.Equal("2/18/2026", result.Output); + Assert.Equal(new DateTime(2026, 2, 18).ToString("d", CultureInfo.CurrentCulture), result.Output); Assert.Equal(0, result.ErrorCount); } @@ -2854,7 +2876,7 @@ public async Task DateTimeMath_CombinedSegments_FourUnits() // = Jan 1 2027 + 2 months = Mar 1 2027 + 7 days = Mar 8 + 3 days = Mar 11 string input = "January 1, 2026 + 1 year 2 months 1 week 3 days"; CalculationResult result = await service.EvaluateExpressionsAsync(input); - Assert.Equal("3/11/2027", result.Output); + Assert.Equal(new DateTime(2027, 3, 11).ToString("d", CultureInfo.CurrentCulture), result.Output); Assert.Equal(0, result.ErrorCount); } @@ -2866,7 +2888,7 @@ public async Task DateTimeMath_CombinedSegments_WithOperatorChange() // = April 1 + 5 days = April 6, then - 2 hours crosses to April 5 10:00pm string input = "March 1, 2026 + 1 month 5 days - 2 hours"; CalculationResult result = await service.EvaluateExpressionsAsync(input); - Assert.Contains("4/5/2026", result.Output); + Assert.Contains(new DateTime(2026, 4, 5).ToString("d", CultureInfo.CurrentCulture), result.Output); Assert.Contains("10:00pm", result.Output.ToLowerInvariant()); Assert.Equal(0, result.ErrorCount); } @@ -2879,7 +2901,7 @@ public async Task DateTimeMath_CombinedSegments_SubtractMultiple() // = March 30 - 10 days = March 20 string input = "April 30, 2026 - 1 month 10 days"; CalculationResult result = await service.EvaluateExpressionsAsync(input); - Assert.Equal("3/20/2026", result.Output); + Assert.Equal(new DateTime(2026, 3, 20).ToString("d", CultureInfo.CurrentCulture), result.Output); Assert.Equal(0, result.ErrorCount); } @@ -2892,7 +2914,7 @@ public async Task DateTimeMath_CombinedSegments_OperatorInheritance() // = June 15 + 14 + 3 = July 2, then - 7 - 2 = June 23 string input = "June 15, 2026 + 2 weeks 3 days - 1 week 2 days"; CalculationResult result = await service.EvaluateExpressionsAsync(input); - Assert.Equal("6/23/2026", result.Output); + Assert.Equal(new DateTime(2026, 6, 23).ToString("d", CultureInfo.CurrentCulture), result.Output); Assert.Equal(0, result.ErrorCount); } @@ -2904,7 +2926,7 @@ public async Task DateTimeMath_CombinedSegments_AllDateUnits() // = Jan 1 2030 + 2 years = Jan 1 2032 + 3 months = Apr 1 2032 + 14 days = Apr 15 + 5 days = Apr 20 string input = "January 1, 2020 + 1 decade 2 years 3 months 2 weeks 5 days"; CalculationResult result = await service.EvaluateExpressionsAsync(input); - Assert.Equal("4/20/2032", result.Output); + Assert.Equal(new DateTime(2032, 4, 20).ToString("d", CultureInfo.CurrentCulture), result.Output); Assert.Equal(0, result.ErrorCount); } @@ -2916,7 +2938,7 @@ public async Task DateTimeMath_CombinedSegments_WithTimeInput() // = 3/2/2026 8:00am + 4:30 = 12:30pm string input = "3/1/2026 8:00am + 1 day 4 hours 30 mins"; CalculationResult result = await service.EvaluateExpressionsAsync(input); - Assert.Contains("3/2/2026", result.Output); + Assert.Contains(new DateTime(2026, 3, 2).ToString("d", CultureInfo.CurrentCulture), result.Output); Assert.Contains("12:30pm", result.Output.ToLowerInvariant()); Assert.Equal(0, result.ErrorCount); } @@ -2928,7 +2950,7 @@ public async Task DateTimeMath_CombinedSegments_DoesNotBreakExplicitOperators() // Existing explicit-operator style should still work identically string input = "January 1, 2026 + 2 weeks + 3 days"; CalculationResult result = await service.EvaluateExpressionsAsync(input); - Assert.Equal("1/18/2026", result.Output); + Assert.Equal(new DateTime(2026, 1, 18).ToString("d", CultureInfo.CurrentCulture), result.Output); Assert.Equal(0, result.ErrorCount); } @@ -2960,7 +2982,7 @@ public async Task DateTimeMath_CombinedSegments_RealWorld_Pregnancy() string input = "June 15, 2026 + 9 months 1 week"; CalculationResult result = await service.EvaluateExpressionsAsync(input); // June 15 + 9 months = March 15 2027, + 1 week = March 22 2027 - Assert.Equal("3/22/2027", result.Output); + Assert.Equal(new DateTime(2027, 3, 22).ToString("d", CultureInfo.CurrentCulture), result.Output); Assert.Equal(0, result.ErrorCount); } @@ -2972,7 +2994,7 @@ public async Task DateTimeMath_CombinedSegments_RealWorld_SprintPlanning() string input = "3/2/2026 9:00am + 2 weeks 3 days 6 hours"; CalculationResult result = await service.EvaluateExpressionsAsync(input); // March 2 + 14 + 3 = March 19, 9am + 6h = 3pm - Assert.Contains("3/19/2026", result.Output); + Assert.Contains(new DateTime(2026, 3, 19).ToString("d", CultureInfo.CurrentCulture), result.Output); Assert.Contains("3:00pm", result.Output.ToLowerInvariant()); Assert.Equal(0, result.ErrorCount); } @@ -2985,7 +3007,7 @@ public async Task DateTimeMath_CombinedSegments_RealWorld_TravelItinerary() string input = "1/10/2026 6:30am + 1 day 14 hours 45 mins"; CalculationResult result = await service.EvaluateExpressionsAsync(input); // Jan 10 6:30am + 1d 14h 45m = Jan 11 6:30am + 14h 45m = Jan 11 9:15pm - Assert.Contains("1/11/2026", result.Output); + Assert.Contains(new DateTime(2026, 1, 11).ToString("d", CultureInfo.CurrentCulture), result.Output); Assert.Contains("9:15pm", result.Output.ToLowerInvariant()); Assert.Equal(0, result.ErrorCount); } @@ -3147,9 +3169,9 @@ public async Task CalculationService_DatePlusDuration_ThenOperatorContinuation() string[] lines = result.Output.Split('\n'); Assert.Equal(2, lines.Length); // March 1 + 2 weeks = March 15 - Assert.Contains("3/15/2026", lines[0]); + Assert.Contains(new DateTime(2026, 3, 15).ToString("d", CultureInfo.CurrentCulture), lines[0]); // March 15 + 1 month = April 15 - Assert.Contains("4/15/2026", lines[1]); + Assert.Contains(new DateTime(2026, 4, 15).ToString("d", CultureInfo.CurrentCulture), lines[1]); Assert.Equal(0, result.ErrorCount); } @@ -3166,9 +3188,9 @@ public async Task CalculationService_DateChainedThreeLines() // Assert string[] lines = result.Output.Split('\n'); Assert.Equal(3, lines.Length); - Assert.Contains("2/1/2026", lines[0]); // Jan 1 + 1 month = Feb 1 - Assert.Contains("3/1/2026", lines[1]); // Feb 1 + 1 month = Mar 1 - Assert.Contains("4/1/2026", lines[2]); // Mar 1 + 1 month = Apr 1 + Assert.Contains(new DateTime(2026, 2, 1).ToString("d", CultureInfo.CurrentCulture), lines[0]); // Jan 1 + 1 month = Feb 1 + Assert.Contains(new DateTime(2026, 3, 1).ToString("d", CultureInfo.CurrentCulture), lines[1]); // Feb 1 + 1 month = Mar 1 + Assert.Contains(new DateTime(2026, 4, 1).ToString("d", CultureInfo.CurrentCulture), lines[2]); // Mar 1 + 1 month = Apr 1 Assert.Equal(0, result.ErrorCount); } @@ -3185,8 +3207,8 @@ public async Task CalculationService_DateMinusDuration_OperatorContinuation() // Assert string[] lines = result.Output.Split('\n'); Assert.Equal(2, lines.Length); - Assert.Contains("4/15/2026", lines[0]); // March 15 + 1 month = April 15 - Assert.Contains("4/8/2026", lines[1]); // April 15 - 1 week = April 8 + Assert.Contains(new DateTime(2026, 4, 15).ToString("d", CultureInfo.CurrentCulture), lines[0]); // March 15 + 1 month = April 15 + Assert.Contains(new DateTime(2026, 4, 8).ToString("d", CultureInfo.CurrentCulture), lines[1]); // April 15 - 1 week = April 8 Assert.Equal(0, result.ErrorCount); } @@ -3223,9 +3245,9 @@ public async Task CalculationService_DateContinuation_CommentDoesNotResetDate() // Assert string[] lines = result.Output.Split('\n'); Assert.Equal(3, lines.Length); - Assert.Contains("2/1/2026", lines[0]); // Jan 1 + 1 month = Feb 1 + Assert.Contains(new DateTime(2026, 2, 1).ToString("d", CultureInfo.CurrentCulture), lines[0]); // Jan 1 + 1 month = Feb 1 Assert.Equal("", lines[1]); // comment - Assert.Contains("3/1/2026", lines[2]); // Feb 1 + 1 month = Mar 1 + Assert.Contains(new DateTime(2026, 3, 1).ToString("d", CultureInfo.CurrentCulture), lines[2]); // Feb 1 + 1 month = Mar 1 Assert.Equal(0, result.ErrorCount); } @@ -3242,7 +3264,7 @@ public async Task CalculationService_DateFollowedByNumericExpression_ResetsDateC // Assert string[] lines = result.Output.Split('\n'); Assert.Equal(2, lines.Length); - Assert.Contains("2/1/2026", lines[0]); + Assert.Contains(new DateTime(2026, 2, 1).ToString("d", CultureInfo.CurrentCulture), lines[0]); Assert.Equal("8", lines[1]); Assert.Equal(0, result.ErrorCount); } diff --git a/Text-Grab/Services/CalculationService.DateTimeMath.cs b/Text-Grab/Services/CalculationService.DateTimeMath.cs index 2593fb3b..04a16177 100644 --- a/Text-Grab/Services/CalculationService.DateTimeMath.cs +++ b/Text-Grab/Services/CalculationService.DateTimeMath.cs @@ -242,7 +242,7 @@ private static string FormatDateTimeResult(DateTime dateTime, bool includeTime) if (includeTime) { string datePart = dateTime.ToString("d", culture); - string timePart = dateTime.ToString("h:mmtt", culture).ToLowerInvariant(); + string timePart = dateTime.ToString("h:mmtt", CultureInfo.InvariantCulture).ToLowerInvariant(); return $"{datePart} {timePart}"; } diff --git a/Text-Grab/Services/CalculationService.UnitMath.cs b/Text-Grab/Services/CalculationService.UnitMath.cs index 50a4c010..48173c2a 100644 --- a/Text-Grab/Services/CalculationService.UnitMath.cs +++ b/Text-Grab/Services/CalculationService.UnitMath.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Text; using UnitsNet; using UnitsNet.Units; @@ -532,12 +533,76 @@ private static bool TryExtractValueAndUnit(string input, out double value, out U string numberStr = match.Groups["number"].Value; string unitStr = match.Groups["unit"].Value.Trim(); - if (!double.TryParse(numberStr, NumberStyles.Any, CultureInfo.InvariantCulture, out value)) + if (!TryParseFlexibleDouble(numberStr, out value)) return false; return TryResolveUnit(unitStr, out unitInfo); } + /// + /// Tries to parse a double from a string using flexible handling of decimal and group separators. + /// Supports inputs like "1,000", "1.000,5", "3,5", etc. + /// + private static bool TryParseFlexibleDouble(string input, out double value) + { + value = 0; + + if (string.IsNullOrWhiteSpace(input)) + return false; + + string normalized = NormalizeNumberString(input); + if (string.IsNullOrEmpty(normalized)) + return false; + + return double.TryParse( + normalized, + NumberStyles.Float | NumberStyles.AllowLeadingSign, + CultureInfo.InvariantCulture, + out value); + } + + /// + /// Normalizes a numeric string to invariant form (dot as decimal, no group separators) + /// by inferring separator roles from the positions of '.' and ',' characters. + /// + private static string NormalizeNumberString(string input) + { + if (string.IsNullOrWhiteSpace(input)) + return string.Empty; + + // Remove spaces and underscores used as group separators + StringBuilder sb = new(); + foreach (char c in input.Trim()) + { + if (c != ' ' && c != '_') + sb.Append(c); + } + + string compact = sb.ToString(); + int commaIndex = compact.IndexOf(','); + int dotIndex = compact.IndexOf('.'); + + if (commaIndex >= 0 && dotIndex >= 0) + { + // Both present: whichever appears last is the decimal separator + if (compact.LastIndexOf('.') > compact.LastIndexOf(',')) + compact = compact.Replace(",", string.Empty); // e.g. "1,234.56" + else + compact = compact.Replace(".", string.Empty).Replace(",", "."); // e.g. "1.234,56" + } + else if (commaIndex >= 0) + { + // Only comma: single comma is decimal ("3,5"), multiple are group separators ("1,000,000") + int lastCommaIndex = compact.LastIndexOf(','); + compact = commaIndex == lastCommaIndex + ? compact.Replace(",", ".") + : compact.Replace(",", string.Empty); + } + // Only dot or no separator: leave as-is for invariant parsing + + return compact; + } + /// /// Looks up a unit string in the unit dictionary. /// diff --git a/Text-Grab/Services/CalculationService.cs b/Text-Grab/Services/CalculationService.cs index 6a457784..26f10512 100644 --- a/Text-Grab/Services/CalculationService.cs +++ b/Text-Grab/Services/CalculationService.cs @@ -68,7 +68,7 @@ public async Task EvaluateExpressionsAsync(string input) // Skip when we have a previous unit result — unit continuation is handled separately. if (previousLineResult.HasValue && !previousDateTimeResult.HasValue && previousUnitResult is null && StartsWithBinaryOperator(trimmedLine)) { - string previousValueStr = previousLineResult.Value.ToString(CultureInfo.InvariantCulture); + string previousValueStr = previousLineResult.Value.ToString(this.CultureInfo); trimmedLine = previousValueStr + " " + trimmedLine; } @@ -189,12 +189,17 @@ public bool IsParameterAssignment(string line) { // Check for assignment pattern: variable = expression // Avoid matching comparison operators (==, !=, <=, >=) - return line.Contains('=') && - !line.Contains("==") && - !line.Contains("!=") && - !line.Contains("<=") && - !line.Contains(">=") && - line.IndexOf('=') == line.LastIndexOf('='); // Ensure single '=' + if (!line.Contains('=') || + line.Contains("==") || + line.Contains("!=") || + line.Contains("<=") || + line.Contains(">=") || + line.IndexOf('=') != line.LastIndexOf('=')) // Ensure single '=' + return false; + + // Require a non-empty RHS so that "x =" falls through to the normal error path + int equalIndex = line.IndexOf('='); + return equalIndex < line.Length - 1 && !string.IsNullOrWhiteSpace(line[(equalIndex + 1)..]); } /// @@ -404,7 +409,7 @@ private async Task HandleParameterAssignmentAsync(string line) }; if (string.IsNullOrEmpty(expr.ExpressionString)) - return string.Empty; + throw new ArgumentException($"Expression for '{variableName}' is empty."); // Set up parameter handler for existing parameters expr.EvaluateParameterAsync += (name, args) => From da044ecba523cb5c84fda4a98832c1df245494e4 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 1 Mar 2026 11:24:49 -0600 Subject: [PATCH 022/109] Add template-based capture system with region support Introduce GrabTemplate, TemplateRegion, and PostGrabContext classes to enable reusable OCR templates for fixed-layout documents. Templates define named, numbered regions and an output format for assembling OCR results. PostGrabContext encapsulates all data from a grab action, supporting both simple and template-based post-processing. This lays the foundation for structured data extraction and advanced OCR workflows. --- Text-Grab/Models/GrabTemplate.cs | 96 +++++++++++++++++++++++++++++ Text-Grab/Models/PostGrabContext.cs | 36 +++++++++++ Text-Grab/Models/TemplateRegion.cs | 66 ++++++++++++++++++++ 3 files changed, 198 insertions(+) create mode 100644 Text-Grab/Models/GrabTemplate.cs create mode 100644 Text-Grab/Models/PostGrabContext.cs create mode 100644 Text-Grab/Models/TemplateRegion.cs diff --git a/Text-Grab/Models/GrabTemplate.cs b/Text-Grab/Models/GrabTemplate.cs new file mode 100644 index 00000000..e21ed33a --- /dev/null +++ b/Text-Grab/Models/GrabTemplate.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; + +namespace Text_Grab.Models; + +/// +/// Defines a reusable capture template: a set of numbered named regions on a fixed-layout +/// document (e.g. a business card or invoice) and an output format string that assembles +/// the OCR results from those regions into final text. +/// +/// Output template syntax: +/// {N} — replaced by the OCR text from region N (1-based) +/// {N:trim} — trimmed OCR text from region N +/// {N:upper} — uppercased OCR text from region N +/// {N:lower} — lowercased OCR text from region N +/// \n — newline +/// \t — tab +/// \\ — literal backslash +/// \{ — literal opening brace +/// +public class GrabTemplate +{ + /// Unique persistent identifier. + public string Id { get; set; } = Guid.NewGuid().ToString(); + + /// Human-readable name shown in menus and list boxes. + public string Name { get; set; } = string.Empty; + + /// Optional description shown as a tooltip. + public string Description { get; set; } = string.Empty; + + /// Date this template was created. + public DateTimeOffset CreatedDate { get; set; } = DateTimeOffset.Now; + + /// Date this template was last used for capture. + public DateTimeOffset? LastUsedDate { get; set; } + + /// + /// Optional path to a reference image the designer shows as the canvas background. + /// May be empty if no reference image was loaded. + /// + public string SourceImagePath { get; set; } = string.Empty; + + /// + /// Width of the reference image (pixels). Used to convert ratio ↔ absolute coordinates. + /// + public double ReferenceImageWidth { get; set; } = 800; + + /// + /// Height of the reference image (pixels). Used to convert ratio ↔ absolute coordinates. + /// + public double ReferenceImageHeight { get; set; } = 600; + + /// + /// The capture regions, each with a 1-based . + /// + public List Regions { get; set; } = []; + + /// + /// Output format string. Use {N}, {N:trim}, {N:upper}, {N:lower}, \n, \t. + /// Example: "Name: {1}\nEmail: {2}\nPhone: {3}" + /// + public string OutputTemplate { get; set; } = string.Empty; + + public GrabTemplate() { } + + public GrabTemplate(string name) + { + Name = name; + } + + /// + /// Returns whether this template has the minimum required data to be executed. + /// + public bool IsValid => + !string.IsNullOrWhiteSpace(Name) + && Regions.Count > 0 + && !string.IsNullOrWhiteSpace(OutputTemplate); + + /// + /// Returns all region numbers referenced in the output template. + /// + public IEnumerable GetReferencedRegionNumbers() + { + System.Text.RegularExpressions.MatchCollection matches = + System.Text.RegularExpressions.Regex.Matches( + OutputTemplate, + @"\{(\d+)(?::[a-z]+)?\}"); + + foreach (System.Text.RegularExpressions.Match match in matches) + { + if (int.TryParse(match.Groups[1].Value, out int number)) + yield return number; + } + } +} diff --git a/Text-Grab/Models/PostGrabContext.cs b/Text-Grab/Models/PostGrabContext.cs new file mode 100644 index 00000000..63707a09 --- /dev/null +++ b/Text-Grab/Models/PostGrabContext.cs @@ -0,0 +1,36 @@ +using System.Windows; +using System.Windows.Media.Imaging; +using Text_Grab.Interfaces; + +namespace Text_Grab.Models; + +/// +/// Carries all context data produced by a grab action and passed through +/// the post-grab action pipeline. This allows actions that need only the +/// OCR text to ignore the extra fields, while template actions can use +/// the capture region and DPI to re-run sub-region OCR. +/// +public record PostGrabContext( + /// The OCR text extracted from the full capture region. + string Text, + + /// + /// The screen rectangle (in physical pixels) that was captured. + /// Used by template execution to derive sub-region rectangles. + /// + Rect CaptureRegion, + + /// The DPI scale factor at capture time. + double DpiScale, + + /// Optional in-memory copy of the captured image. + BitmapSource? CapturedImage, + + /// The OCR language used for the capture. Null means use the app default. + ILanguage? Language = null +) +{ + /// Convenience factory for non-template actions that only need text. + public static PostGrabContext TextOnly(string text) => + new(text, Rect.Empty, 1.0, null, null); +} diff --git a/Text-Grab/Models/TemplateRegion.cs b/Text-Grab/Models/TemplateRegion.cs new file mode 100644 index 00000000..f2a16117 --- /dev/null +++ b/Text-Grab/Models/TemplateRegion.cs @@ -0,0 +1,66 @@ +using System.Windows; + +namespace Text_Grab.Models; + +/// +/// Defines a named, numbered capture region within a GrabTemplate. +/// Positions are stored as ratios (0.0–1.0) of the reference image dimensions +/// so the template scales to any screen size or DPI. +/// +public class TemplateRegion +{ + /// + /// 1-based number shown on the region border and used in the output template as {RegionNumber}. + /// + public int RegionNumber { get; set; } = 1; + + /// + /// Optional friendly label for this region (e.g. "Name", "Email"). + /// Displayed on the border in the designer. + /// + public string Label { get; set; } = string.Empty; + + /// + /// Position and size as ratios of the reference image dimensions (each value 0.0–1.0). + /// X, Y, Width, Height correspond to left, top, width, height proportions. + /// + public double RatioLeft { get; set; } = 0; + public double RatioTop { get; set; } = 0; + public double RatioWidth { get; set; } = 0; + public double RatioHeight { get; set; } = 0; + + /// + /// Optional default/fallback value used when OCR returns empty for this region. + /// + public string DefaultValue { get; set; } = string.Empty; + + public TemplateRegion() { } + + /// + /// Returns the absolute pixel Rect for this region given the canvas/image dimensions. + /// + public Rect ToAbsoluteRect(double imageWidth, double imageHeight) + { + return new Rect( + x: RatioLeft * imageWidth, + y: RatioTop * imageHeight, + width: RatioWidth * imageWidth, + height: RatioHeight * imageHeight); + } + + /// + /// Sets ratio values from an absolute Rect and canvas dimensions. + /// + public static TemplateRegion FromAbsoluteRect(Rect rect, double imageWidth, double imageHeight, int regionNumber, string label = "") + { + return new TemplateRegion + { + RegionNumber = regionNumber, + Label = label, + RatioLeft = imageWidth > 0 ? rect.X / imageWidth : 0, + RatioTop = imageHeight > 0 ? rect.Y / imageHeight : 0, + RatioWidth = imageWidth > 0 ? rect.Width / imageWidth : 0, + RatioHeight = imageHeight > 0 ? rect.Height / imageHeight : 0, + }; + } +} From 2dc268c5ea6db7e26528e955867beea2f03701b1 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 1 Mar 2026 11:25:54 -0600 Subject: [PATCH 023/109] Add file-based GrabTemplate storage and executor Replaces settings-based template storage with a robust file-based system (GrabTemplateManager) supporting CRUD, migration, and error handling. Implements GrabTemplateExecutor for OCR region extraction and flexible output template formatting with modifiers and escapes. Includes comprehensive unit tests for both manager and executor. --- Tests/GrabTemplateExecutorTests.cs | 146 +++++++++++++ Tests/GrabTemplateManagerTests.cs | 230 ++++++++++++++++++++ Text-Grab/Utilities/GrabTemplateExecutor.cs | 186 ++++++++++++++++ Text-Grab/Utilities/GrabTemplateManager.cs | 227 +++++++++++++++++++ 4 files changed, 789 insertions(+) create mode 100644 Tests/GrabTemplateExecutorTests.cs create mode 100644 Tests/GrabTemplateManagerTests.cs create mode 100644 Text-Grab/Utilities/GrabTemplateExecutor.cs create mode 100644 Text-Grab/Utilities/GrabTemplateManager.cs diff --git a/Tests/GrabTemplateExecutorTests.cs b/Tests/GrabTemplateExecutorTests.cs new file mode 100644 index 00000000..94f00754 --- /dev/null +++ b/Tests/GrabTemplateExecutorTests.cs @@ -0,0 +1,146 @@ +using Text_Grab.Utilities; + +namespace Tests; + +public class GrabTemplateExecutorTests +{ + // ── ApplyOutputTemplate – basic substitution ────────────────────────────── + + [Fact] + public void ApplyOutputTemplate_SingleRegion_SubstitutesCorrectly() + { + Dictionary regions = new() { [1] = "Alice" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("Name: {1}", regions); + Assert.Equal("Name: Alice", result); + } + + [Fact] + public void ApplyOutputTemplate_MultipleRegions_SubstitutesAll() + { + Dictionary regions = new() + { + [1] = "Alice", + [2] = "alice@example.com" + }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1} <{2}>", regions); + Assert.Equal("Alice ", result); + } + + [Fact] + public void ApplyOutputTemplate_MissingRegion_ReplacesWithEmpty() + { + Dictionary regions = new() { [1] = "Alice" }; + // Region 2 not present + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1} {2}", regions); + Assert.Equal("Alice ", result); + } + + [Fact] + public void ApplyOutputTemplate_EmptyTemplate_ReturnsEmpty() + { + Dictionary regions = new() { [1] = "value" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate(string.Empty, regions); + Assert.Equal(string.Empty, result); + } + + // ── Modifiers ────────────────────────────────────────────────────────────── + + [Fact] + public void ApplyOutputTemplate_TrimModifier_TrimsWhitespace() + { + Dictionary regions = new() { [1] = " hello " }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1:trim}", regions); + Assert.Equal("hello", result); + } + + [Fact] + public void ApplyOutputTemplate_UpperModifier_ConvertsToUpper() + { + Dictionary regions = new() { [1] = "hello" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1:upper}", regions); + Assert.Equal("HELLO", result); + } + + [Fact] + public void ApplyOutputTemplate_LowerModifier_ConvertsToLower() + { + Dictionary regions = new() { [1] = "HELLO" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1:lower}", regions); + Assert.Equal("hello", result); + } + + [Fact] + public void ApplyOutputTemplate_UnknownModifier_LeavesTextAsIs() + { + Dictionary regions = new() { [1] = "hello" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1:unknown}", regions); + Assert.Equal("hello", result); + } + + // ── Escape sequences ────────────────────────────────────────────────────── + + [Fact] + public void ApplyOutputTemplate_NewlineEscape_InsertsNewline() + { + Dictionary regions = new() { [1] = "A", [2] = "B" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1}\\n{2}", regions); + Assert.Equal("A\nB", result); + } + + [Fact] + public void ApplyOutputTemplate_TabEscape_InsertsTab() + { + Dictionary regions = new() { [1] = "A", [2] = "B" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1}\\t{2}", regions); + Assert.Equal("A\tB", result); + } + + [Fact] + public void ApplyOutputTemplate_LiteralBraceEscape_PreservesBrace() + { + Dictionary regions = new() { [1] = "value" }; + // \{ in template produces literal {, then {1} → value, then literal text } + string result = GrabTemplateExecutor.ApplyOutputTemplate("\\{{1}}", regions); + Assert.Equal("{value}", result); + } + + [Fact] + public void ApplyOutputTemplate_DoubleBackslash_PreservesBackslash() + { + Dictionary regions = new() { [1] = "A" }; + string result = GrabTemplateExecutor.ApplyOutputTemplate("{1}\\\\{1}", regions); + Assert.Equal(@"A\A", result); + } + + // ── ValidateOutputTemplate ──────────────────────────────────────────────── + + [Fact] + public void ValidateOutputTemplate_ValidTemplate_ReturnsNoIssues() + { + List issues = GrabTemplateExecutor.ValidateOutputTemplate("{1} {2}", [1, 2]); + Assert.Empty(issues); + } + + [Fact] + public void ValidateOutputTemplate_OutOfRangeRegion_ReturnsIssue() + { + List issues = GrabTemplateExecutor.ValidateOutputTemplate("{3}", [1, 2]); + Assert.NotEmpty(issues); + Assert.Contains(issues, i => i.Contains('3')); + } + + [Fact] + public void ValidateOutputTemplate_EmptyTemplate_ReturnsIssue() + { + List issues = GrabTemplateExecutor.ValidateOutputTemplate(string.Empty, [1]); + Assert.NotEmpty(issues); + } + + [Fact] + public void ValidateOutputTemplate_NoRegionsReferenced_ReturnsIssue() + { + // Template has no {N} references + List issues = GrabTemplateExecutor.ValidateOutputTemplate("static text", [1, 2]); + Assert.NotEmpty(issues); + } +} diff --git a/Tests/GrabTemplateManagerTests.cs b/Tests/GrabTemplateManagerTests.cs new file mode 100644 index 00000000..e99d92fd --- /dev/null +++ b/Tests/GrabTemplateManagerTests.cs @@ -0,0 +1,230 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Text_Grab.Models; +using Text_Grab.Utilities; + +namespace Tests; + +public class GrabTemplateManagerTests : IDisposable +{ + // Use a temp file so tests don't pollute each other or real user data + private readonly string _tempFilePath; + + public GrabTemplateManagerTests() + { + _tempFilePath = Path.Combine(Path.GetTempPath(), $"GrabTemplates_Test_{Guid.NewGuid()}.json"); + GrabTemplateManager.TestFilePath = _tempFilePath; + } + + public void Dispose() + { + GrabTemplateManager.TestFilePath = null; + if (File.Exists(_tempFilePath)) + File.Delete(_tempFilePath); + } + + // ── GetAllTemplates ─────────────────────────────────────────────────────── + + [Fact] + public void GetAllTemplates_WhenEmpty_ReturnsEmptyList() + { + List templates = GrabTemplateManager.GetAllTemplates(); + Assert.Empty(templates); + } + + [Fact] + public void GetAllTemplates_AfterAddingTemplate_ReturnsSavedTemplate() + { + GrabTemplate template = CreateSampleTemplate("Invoice"); + GrabTemplateManager.AddOrUpdateTemplate(template); + + List templates = GrabTemplateManager.GetAllTemplates(); + Assert.Single(templates); + Assert.Equal("Invoice", templates[0].Name); + } + + // ── GetTemplateById ─────────────────────────────────────────────────────── + + [Fact] + public void GetTemplateById_ExistingId_ReturnsTemplate() + { + GrabTemplate template = CreateSampleTemplate("Business Card"); + GrabTemplateManager.AddOrUpdateTemplate(template); + + GrabTemplate? found = GrabTemplateManager.GetTemplateById(template.Id); + + Assert.NotNull(found); + Assert.Equal(template.Id, found.Id); + Assert.Equal("Business Card", found.Name); + } + + [Fact] + public void GetTemplateById_NonExistentId_ReturnsNull() + { + GrabTemplate? found = GrabTemplateManager.GetTemplateById("non-existent-id"); + Assert.Null(found); + } + + // ── AddOrUpdateTemplate ─────────────────────────────────────────────────── + + [Fact] + public void AddOrUpdateTemplate_AddNew_IncrementsCount() + { + GrabTemplateManager.AddOrUpdateTemplate(CreateSampleTemplate("T1")); + GrabTemplateManager.AddOrUpdateTemplate(CreateSampleTemplate("T2")); + + List templates = GrabTemplateManager.GetAllTemplates(); + Assert.Equal(2, templates.Count); + } + + [Fact] + public void AddOrUpdateTemplate_UpdateExisting_ReplacesByIdNotDuplicate() + { + GrabTemplate original = CreateSampleTemplate("Original Name"); + GrabTemplateManager.AddOrUpdateTemplate(original); + + original.Name = "Updated Name"; + GrabTemplateManager.AddOrUpdateTemplate(original); + + List templates = GrabTemplateManager.GetAllTemplates(); + Assert.Single(templates); + Assert.Equal("Updated Name", templates[0].Name); + } + + // ── DeleteTemplate ──────────────────────────────────────────────────────── + + [Fact] + public void DeleteTemplate_ExistingId_RemovesTemplate() + { + GrabTemplate template = CreateSampleTemplate("ToDelete"); + GrabTemplateManager.AddOrUpdateTemplate(template); + + GrabTemplateManager.DeleteTemplate(template.Id); + + List templates = GrabTemplateManager.GetAllTemplates(); + Assert.Empty(templates); + } + + [Fact] + 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() + { + GrabTemplate original = CreateSampleTemplate("My Template"); + GrabTemplateManager.AddOrUpdateTemplate(original); + + GrabTemplate? copy = GrabTemplateManager.DuplicateTemplate(original.Id); + + Assert.NotNull(copy); + Assert.NotEqual(original.Id, copy.Id); + Assert.Contains("(copy)", copy.Name); + Assert.Equal(2, GrabTemplateManager.GetAllTemplates().Count); + } + + [Fact] + public void DuplicateTemplate_NonExistentId_ReturnsNull() + { + GrabTemplate? copy = GrabTemplateManager.DuplicateTemplate("not-there"); + Assert.Null(copy); + } + + // ── CreateButtonInfoForTemplate ─────────────────────────────────────────── + + [Fact] + public void CreateButtonInfoForTemplate_SetsTemplateId() + { + GrabTemplate template = CreateSampleTemplate("Card"); + + Text_Grab.Models.ButtonInfo button = GrabTemplateManager.CreateButtonInfoForTemplate(template); + + Assert.Equal(template.Id, button.TemplateId); + Assert.Equal("ApplyTemplate_Click", button.ClickEvent); + Assert.Equal(template.Name, button.ButtonText); + } + + // ── Corrupt JSON robustness ─────────────────────────────────────────────── + + [Fact] + public void GetAllTemplates_CorruptJson_ReturnsEmptyList() + { + File.WriteAllText(_tempFilePath, "{ this is not valid json }}}"); + + List templates = GrabTemplateManager.GetAllTemplates(); + Assert.Empty(templates); + } + + // ── GrabTemplate model ──────────────────────────────────────────────────── + + [Fact] + public void GrabTemplate_IsValid_TrueWhenNameRegionsAndOutputTemplateSet() + { + GrabTemplate template = CreateSampleTemplate("Valid"); + Assert.True(template.IsValid); + } + + [Fact] + public void GrabTemplate_IsValid_FalseWhenNameEmpty() + { + GrabTemplate template = CreateSampleTemplate(string.Empty); + Assert.False(template.IsValid); + } + + [Fact] + public void GrabTemplate_IsValid_FalseWhenNoRegions() + { + GrabTemplate template = CreateSampleTemplate("No Regions"); + template.Regions.Clear(); + Assert.False(template.IsValid); + } + + [Fact] + public void GrabTemplate_GetReferencedRegionNumbers_ParsesPlaceholders() + { + GrabTemplate template = CreateSampleTemplate("Multi"); + template.OutputTemplate = "{1} {2} {1:upper}"; + + HashSet referenced = template.GetReferencedRegionNumbers().ToHashSet(); + + Assert.Contains(1, referenced); + Assert.Contains(2, referenced); + Assert.Equal(2, referenced.Count); + } + + // ── Helper ──────────────────────────────────────────────────────────────── + + private static GrabTemplate CreateSampleTemplate(string name) + { + return new GrabTemplate + { + Id = Guid.NewGuid().ToString(), + Name = name, + Description = "Test template", + OutputTemplate = "{1}", + ReferenceImageWidth = 800, + ReferenceImageHeight = 600, + Regions = + [ + new Text_Grab.Models.TemplateRegion + { + RegionNumber = 1, + Label = "Field 1", + RatioLeft = 0.1, + RatioTop = 0.1, + RatioWidth = 0.5, + RatioHeight = 0.1, + } + ] + }; + } +} diff --git a/Text-Grab/Utilities/GrabTemplateExecutor.cs b/Text-Grab/Utilities/GrabTemplateExecutor.cs new file mode 100644 index 00000000..e3d3add4 --- /dev/null +++ b/Text-Grab/Utilities/GrabTemplateExecutor.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Windows; +using Text_Grab.Interfaces; +using Text_Grab.Models; + +namespace Text_Grab.Utilities; + +/// +/// Executes a against a captured screen region: +/// OCRs each sub-region, then formats the results using the template's +/// string. +/// +/// Output template syntax: +/// {N} — OCR text from region N (1-based) +/// {N:trim} — trimmed OCR text +/// {N:upper} — uppercased OCR text +/// {N:lower} — lowercased OCR text +/// \n — newline +/// \t — tab +/// \\ — literal backslash +/// \{ — literal opening brace +/// +public static class GrabTemplateExecutor +{ + // Matches {N} or {N:modifier} where N is one or more digits + private static readonly Regex PlaceholderRegex = + new(@"\{(\d+)(?::([a-z]+))?\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + // ── Public API ──────────────────────────────────────────────────────────── + + /// + /// Executes the given template using as the + /// coordinate space. Each template region is mapped to a sub-rectangle of + /// , OCR'd, then assembled via the output template. + /// + /// The template to execute. + /// + /// The screen rectangle (in WPF units, pre-DPI-scaling applied by caller) + /// that bounds the user's selection. Template region ratios are applied to + /// this rectangle's width/height. + /// + /// The OCR language to use. Pass null to use the app default. + public static async Task ExecuteTemplateAsync( + GrabTemplate template, + Rect captureRegion, + ILanguage? language = null) + { + if (!template.IsValid) + return string.Empty; + + // 1. OCR each region + ILanguage resolvedLanguage = language ?? LanguageUtilities.GetOCRLanguage(); + Dictionary regionResults = await OcrAllRegionsAsync( + template, captureRegion, resolvedLanguage); + + // 2. Apply output template + return ApplyOutputTemplate(template.OutputTemplate, regionResults); + } + + /// + /// Applies the output template string with the provided region text values. + /// Useful for unit testing the string processing independently of OCR. + /// + public static string ApplyOutputTemplate( + string outputTemplate, + IReadOnlyDictionary regionResults) + { + if (string.IsNullOrEmpty(outputTemplate)) + return string.Empty; + + // Replace escape sequences first + string processed = outputTemplate + .Replace(@"\\", "\x00BACKSLASH\x00") // protect real backslashes + .Replace(@"\n", "\n") + .Replace(@"\t", "\t") + .Replace(@"\{", "\x00LBRACE\x00") // protect literal braces + .Replace("\x00BACKSLASH\x00", @"\"); + + // Replace {N} / {N:modifier} placeholders + string result = PlaceholderRegex.Replace(processed, match => + { + if (!int.TryParse(match.Groups[1].Value, out int regionNumber)) + return match.Value; // leave unknown placeholders as-is + + regionResults.TryGetValue(regionNumber, out string? text); + text ??= string.Empty; + + string modifier = match.Groups[2].Success + ? match.Groups[2].Value.ToLowerInvariant() + : string.Empty; + + return modifier switch + { + "trim" => text.Trim(), + "upper" => text.ToUpper(), + "lower" => text.ToLower(), + _ => text + }; + }); + + // Restore protected literal characters + result = result.Replace("\x00LBRACE\x00", "{"); + + return result; + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + private static async Task> OcrAllRegionsAsync( + GrabTemplate template, + Rect captureRegion, + ILanguage language) + { + Dictionary results = []; + + foreach (TemplateRegion region in template.Regions) + { + // Compute absolute screen rect from capture region + region ratios + Rect absoluteRegionRect = new( + x: captureRegion.X + region.RatioLeft * captureRegion.Width, + y: captureRegion.Y + region.RatioTop * captureRegion.Height, + width: region.RatioWidth * captureRegion.Width, + height: region.RatioHeight * captureRegion.Height); + + if (absoluteRegionRect.Width <= 0 || absoluteRegionRect.Height <= 0) + { + results[region.RegionNumber] = region.DefaultValue; + continue; + } + + try + { + // GetTextFromAbsoluteRectAsync uses absolute screen coordinates + string regionText = await OcrUtilities.GetTextFromAbsoluteRectAsync(absoluteRegionRect, language); + // Use default value when OCR returns nothing + results[region.RegionNumber] = string.IsNullOrWhiteSpace(regionText) + ? region.DefaultValue + : regionText.Trim(); + } + catch (Exception) + { + results[region.RegionNumber] = region.DefaultValue; + } + } + + return results; + } + + /// + /// Validates the output template syntax and returns a list of issues. + /// Returns an empty list when valid. + /// + public static List ValidateOutputTemplate(string outputTemplate, IEnumerable availableRegionNumbers) + { + List issues = []; + HashSet available = [.. availableRegionNumbers]; + + MatchCollection matches = PlaceholderRegex.Matches(outputTemplate); + HashSet referenced = []; + + foreach (Match match in matches) + { + if (!int.TryParse(match.Groups[1].Value, out int num)) + { + issues.Add($"Invalid placeholder: {match.Value}"); + continue; + } + + if (!available.Contains(num)) + issues.Add($"Placeholder {{{{num}}}} references region {num} which does not exist."); + + referenced.Add(num); + } + + foreach (int availableNum in available) + { + if (!referenced.Contains(availableNum)) + issues.Add($"Region {availableNum} is defined but not used in the output template."); + } + + return issues; + } +} diff --git a/Text-Grab/Utilities/GrabTemplateManager.cs b/Text-Grab/Utilities/GrabTemplateManager.cs new file mode 100644 index 00000000..6f01f9f2 --- /dev/null +++ b/Text-Grab/Utilities/GrabTemplateManager.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; +using Text_Grab.Models; +using Text_Grab.Properties; +using Wpf.Ui.Controls; + +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 . +/// +public static class GrabTemplateManager +{ + private static readonly Settings DefaultSettings = AppUtilities.TextGrabSettings; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + }; + + private const string TemplatesFileName = "GrabTemplates.json"; + private static bool _migrated; + + // Allow tests to override the file path + internal static string? TestFilePath { get; set; } + + // ── File path ───────────────────────────────────────────────────────────── + + private static string GetTemplatesFilePath() + { + if (TestFilePath is not null) + return TestFilePath; + + if (AppUtilities.IsPackaged()) + { + string localFolder = Windows.Storage.ApplicationData.Current.LocalFolder.Path; + return Path.Combine(localFolder, TemplatesFileName); + } + + string? exeDir = Path.GetDirectoryName(FileUtilities.GetExePath()); + return Path.Combine(exeDir ?? "c:\\Text-Grab", TemplatesFileName); + } + + // ── 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); + + if (string.IsNullOrWhiteSpace(json)) + return []; + + List? templates = JsonSerializer.Deserialize>(json, JsonOptions); + if (templates is not null) + return templates; + } + catch (JsonException) + { + // Return empty list if deserialization fails — never crash + } + catch (IOException ex) + { + Debug.WriteLine($"Failed to read GrabTemplates file: {ex.Message}"); + } + + return []; + } + + /// Returns the template with the given ID, or null. + public static GrabTemplate? GetTemplateById(string id) + { + if (string.IsNullOrWhiteSpace(id)) + return null; + + return GetAllTemplates().FirstOrDefault(t => t.Id == id); + } + + // ── Write ───────────────────────────────────────────────────────────────── + + /// Replaces the entire saved template list. + public static void SaveTemplates(List templates) + { + string json = JsonSerializer.Serialize(templates, JsonOptions); + string filePath = GetTemplatesFilePath(); + + string? dir = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + File.WriteAllText(filePath, json); + } + + /// Adds a new template (or updates an existing one with the same ID). + public static void AddOrUpdateTemplate(GrabTemplate template) + { + List templates = GetAllTemplates(); + int existing = templates.FindIndex(t => t.Id == template.Id); + if (existing >= 0) + templates[existing] = template; + else + templates.Add(template); + + SaveTemplates(templates); + } + + /// Removes the template with the given ID. No-op if not found. + public static void DeleteTemplate(string id) + { + List templates = GetAllTemplates(); + int removed = templates.RemoveAll(t => t.Id == id); + if (removed > 0) + SaveTemplates(templates); + } + + /// Creates and saves a shallow copy of an existing template with a new ID and name. + public static GrabTemplate? DuplicateTemplate(string id) + { + GrabTemplate? original = GetTemplateById(id); + if (original is null) + return null; + + string json = JsonSerializer.Serialize(original, JsonOptions); + GrabTemplate? copy = JsonSerializer.Deserialize(json, JsonOptions); + if (copy is null) + return null; + + copy.Id = Guid.NewGuid().ToString(); + copy.Name = $"{original.Name} (copy)"; + copy.CreatedDate = DateTimeOffset.Now; + copy.LastUsedDate = null; + + AddOrUpdateTemplate(copy); + return copy; + } + + // ── ButtonInfo bridge ───────────────────────────────────────────────────── + + /// + /// Generates a post-grab action that executes the given template. + /// + public static ButtonInfo CreateButtonInfoForTemplate(GrabTemplate template) + { + return new ButtonInfo( + buttonText: template.Name, + clickEvent: "ApplyTemplate_Click", + symbolIcon: SymbolRegular.DocumentTableSearch24, + defaultCheckState: DefaultCheckState.Off) + { + TemplateId = template.Id, + IsRelevantForFullscreenGrab = true, + IsRelevantForEditWindow = false, + OrderNumber = 7.0, + }; + } + + /// + /// Updates a 's LastUsedDate and persists it. + /// + public static void RecordUsage(string templateId) + { + List templates = GetAllTemplates(); + GrabTemplate? template = templates.FirstOrDefault(t => t.Id == templateId); + if (template is null) + return; + + template.LastUsedDate = DateTimeOffset.Now; + SaveTemplates(templates); + } +} From ae3aa3da7a618a6ce0bf8765e282a96228aad3f9 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 1 Mar 2026 11:28:57 -0600 Subject: [PATCH 024/109] Add Grab Template support as post-grab actions - Add TemplateId property to ButtonInfo for template actions - Include Grab Templates in available post-grab actions - Add ApplyTemplate_Click action handling in PostGrabActionManager - Overload ExecutePostGrabAction to accept PostGrabContext - Improve BarcodeUtilities with null checks and error handling - Update comments and summaries for clarity and maintainability --- Text-Grab/Models/ButtonInfo.cs | 6 +++ Text-Grab/Utilities/BarcodeUtilities.cs | 17 +++++++- Text-Grab/Utilities/PostGrabActionManager.cs | 44 ++++++++++++++++++-- 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/Text-Grab/Models/ButtonInfo.cs b/Text-Grab/Models/ButtonInfo.cs index a21b39e9..ce3706c8 100644 --- a/Text-Grab/Models/ButtonInfo.cs +++ b/Text-Grab/Models/ButtonInfo.cs @@ -30,6 +30,12 @@ public class ButtonInfo public bool IsRelevantForEditWindow { get; set; } = true; // Default to true for backward compatibility public DefaultCheckState DefaultCheckState { get; set; } = DefaultCheckState.Off; + /// + /// When this ButtonInfo represents a Grab Template action, this holds the template's + /// unique ID so the executor can look it up. Empty for non-template actions. + /// + public string TemplateId { get; set; } = string.Empty; + public ButtonInfo() { diff --git a/Text-Grab/Utilities/BarcodeUtilities.cs b/Text-Grab/Utilities/BarcodeUtilities.cs index 40385f79..45cd0928 100644 --- a/Text-Grab/Utilities/BarcodeUtilities.cs +++ b/Text-Grab/Utilities/BarcodeUtilities.cs @@ -14,13 +14,26 @@ public static class BarcodeUtilities public static OcrOutput TryToReadBarcodes(Bitmap bitmap) { + if (bitmap is null || bitmap.Width <= 0 || bitmap.Height <= 0) + return new OcrOutput() { Kind = OcrOutputKind.Barcode, RawOutput = string.Empty }; + BarcodeReader barcodeReader = new() { AutoRotate = true, Options = new ZXing.Common.DecodingOptions { TryHarder = true } }; - ZXing.Result result = barcodeReader.Decode(bitmap); + Result? result = null; + + try + { + result = barcodeReader.Decode(bitmap); + + } + catch (System.Exception) + { + return new OcrOutput() { Kind = OcrOutputKind.Barcode, RawOutput = string.Empty }; + } string resultString = string.Empty; if (result is not null) @@ -81,4 +94,4 @@ public static SvgImage GetSvgQrCodeForText(string text, ErrorCorrectionLevel cor return svg; } -} \ No newline at end of file +} diff --git a/Text-Grab/Utilities/PostGrabActionManager.cs b/Text-Grab/Utilities/PostGrabActionManager.cs index c5242595..00693031 100644 --- a/Text-Grab/Utilities/PostGrabActionManager.cs +++ b/Text-Grab/Utilities/PostGrabActionManager.cs @@ -4,6 +4,8 @@ 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; @@ -15,7 +17,8 @@ public class PostGrabActionManager private static readonly Settings DefaultSettings = AppUtilities.TextGrabSettings; /// - /// Gets all available post-grab actions from ButtonInfo.AllButtons filtered for FullscreenGrab relevance + /// Gets all available post-grab actions from ButtonInfo.AllButtons filtered for FullscreenGrab relevance. + /// Also includes a ButtonInfo for each saved Grab Template. /// public static List GetAvailablePostGrabActions() { @@ -24,9 +27,19 @@ public static List GetAvailablePostGrabActions() // Add other relevant actions from AllButtons that are marked as relevant for FullscreenGrab IEnumerable relevantActions = ButtonInfo.AllButtons .Where(button => button.IsRelevantForFullscreenGrab && !allPostGrabActions.Any(b => b.ButtonText == button.ButtonText)); - + allPostGrabActions.AddRange(relevantActions); + // Add a ButtonInfo for each saved Grab Template + List templates = GrabTemplateManager.GetAllTemplates(); + foreach (GrabTemplate template in templates) + { + ButtonInfo templateAction = GrabTemplateManager.CreateButtonInfoForTemplate(template); + // Avoid duplicates if it's somehow already in the list + if (!allPostGrabActions.Any(b => b.TemplateId == template.Id)) + allPostGrabActions.Add(templateAction); + } + return [.. allPostGrabActions.OrderBy(b => b.OrderNumber)]; } @@ -142,7 +155,7 @@ public static bool GetCheckState(ButtonInfo action) try { Dictionary? checkStates = JsonSerializer.Deserialize>(statesJson); - if (checkStates is not null + if (checkStates is not null && checkStates.TryGetValue(action.ButtonText, out bool storedState) && action.DefaultCheckState == DefaultCheckState.LastUsed) { @@ -191,6 +204,16 @@ public static void SaveCheckState(ButtonInfo action, bool isChecked) /// public static async Task ExecutePostGrabAction(ButtonInfo action, string text) { + return await ExecutePostGrabAction(action, PostGrabContext.TextOnly(text)); + } + + /// + /// Executes a post-grab action using the full . + /// Template actions use the context's CaptureRegion and DpiScale to re-OCR sub-regions. + /// + public static async Task ExecutePostGrabAction(ButtonInfo action, PostGrabContext context) + { + string text = context.Text; string result = text; switch (action.ClickEvent) @@ -236,6 +259,21 @@ public static async Task ExecutePostGrabAction(ButtonInfo action, string } break; + case "ApplyTemplate_Click": + if (!string.IsNullOrWhiteSpace(action.TemplateId) + && context.CaptureRegion != Rect.Empty) + { + GrabTemplate? template = GrabTemplateManager.GetTemplateById(action.TemplateId); + if (template is not null) + { + result = await GrabTemplateExecutor.ExecuteTemplateAsync( + template, context.CaptureRegion, context.Language); + GrabTemplateManager.RecordUsage(action.TemplateId); + } + } + // If no capture region (e.g. called from EditTextWindow), skip template + break; + default: // Unknown action - return text unchanged break; From 376b0faefa24f41a4d85981e956c848f8ec960e6 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 1 Mar 2026 11:30:03 -0600 Subject: [PATCH 025/109] Add new user settings for templates and context menu options Added three user-scoped settings: GrabTemplatesJSON (string), AddToContextMenu (bool), and RegisterOpenWith (bool). Updated App.config, Settings.settings, and Settings.Designer.cs to support these options. Also updated the code generation version in Settings.Designer.cs. --- Text-Grab/App.config | 9 +++++++++ Text-Grab/Properties/Settings.Designer.cs | 16 ++++++++++++++-- Text-Grab/Properties/Settings.settings | 3 +++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/Text-Grab/App.config b/Text-Grab/App.config index ab9b17f7..4675d28e 100644 --- a/Text-Grab/App.config +++ b/Text-Grab/App.config @@ -211,6 +211,15 @@ False + + + + + False + + + False + \ No newline at end of file diff --git a/Text-Grab/Properties/Settings.Designer.cs b/Text-Grab/Properties/Settings.Designer.cs index d223017a..6c4d60c4 100644 --- a/Text-Grab/Properties/Settings.Designer.cs +++ b/Text-Grab/Properties/Settings.Designer.cs @@ -12,7 +12,7 @@ namespace Text_Grab.Properties { [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "17.14.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "18.4.0.0")] internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); @@ -839,6 +839,18 @@ public bool PostGrabStayOpen { } } + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("")] + public string GrabTemplatesJSON { + get { + return ((string)(this["GrabTemplatesJSON"])); + } + set { + this["GrabTemplatesJSON"] = value; + } + } + [global::System.Configuration.UserScopedSettingAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Configuration.DefaultSettingValueAttribute("False")] @@ -850,7 +862,7 @@ public bool AddToContextMenu { this["AddToContextMenu"] = value; } } - + [global::System.Configuration.UserScopedSettingAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Configuration.DefaultSettingValueAttribute("False")] diff --git a/Text-Grab/Properties/Settings.settings b/Text-Grab/Properties/Settings.settings index 63fb72c1..ac20c5b4 100644 --- a/Text-Grab/Properties/Settings.settings +++ b/Text-Grab/Properties/Settings.settings @@ -206,6 +206,9 @@ False + + + False From 6fb2a4b82cbc94abb49ebd04d7ddacbafab08eba Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 1 Mar 2026 11:30:21 -0600 Subject: [PATCH 026/109] Handle ApplicationDataContainer size limit exception Add specific handling for COMException when saving settings exceeds the ApplicationDataContainer 8 KB size limit. Log a clear debug message suggesting large data be stored in a file. Also, clarify the generic exception debug message. --- Text-Grab/Services/SettingsService.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Text-Grab/Services/SettingsService.cs b/Text-Grab/Services/SettingsService.cs index fd1cddd8..73bc7d24 100644 --- a/Text-Grab/Services/SettingsService.cs +++ b/Text-Grab/Services/SettingsService.cs @@ -98,9 +98,15 @@ public void SaveSettingInContainer(string name, T value) { _localSettings.Values[name] = value; } + catch (System.Runtime.InteropServices.COMException ex) when (ex.HResult == unchecked((int)0x80073DC8)) + { + // The value exceeds the ApplicationDataContainer size limit (8 KB). + // Large data should be stored in a file instead. + Debug.WriteLine($"Setting '{name}' exceeds ApplicationDataContainer size limit: {ex.Message}"); + } catch (Exception ex) { - Debug.WriteLine($"Failed to Save setting from ApplicationDataContainer {ex.Message}"); + Debug.WriteLine($"Failed to Save setting in ApplicationDataContainer: {ex.Message}"); #if DEBUG throw; #endif From 58b7ba37b1c2b31c7f8688444e24f7c5cf38af0c Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 1 Mar 2026 11:32:04 -0600 Subject: [PATCH 027/109] Add Grab Templates management to Post-Grab Actions editor Introduce a new "Grab Templates" section in the Post-Grab Actions editor, allowing users to create, view, and delete OCR region templates. Add UI for managing templates, including a list view and actions to open the Grab Frame or delete templates. Update Fullscreen Grab settings to link to the template management UI, making template-based OCR more accessible and discoverable. Includes supporting code for loading, refreshing, and deleting templates, as well as minor UI consistency improvements. --- Text-Grab/Controls/PostGrabActionEditor.xaml | 220 ++++++++++++++---- .../Controls/PostGrabActionEditor.xaml.cs | 69 ++++++ Text-Grab/Pages/FullscreenGrabSettings.xaml | 58 +++-- .../Pages/FullscreenGrabSettings.xaml.cs | 12 + 4 files changed, 297 insertions(+), 62 deletions(-) diff --git a/Text-Grab/Controls/PostGrabActionEditor.xaml b/Text-Grab/Controls/PostGrabActionEditor.xaml index 3126b4c2..9f6416f8 100644 --- a/Text-Grab/Controls/PostGrabActionEditor.xaml +++ b/Text-Grab/Controls/PostGrabActionEditor.xaml @@ -1,4 +1,4 @@ - - + - + - - - + + + - - - + + + + + Icon="{StaticResource TextGrabIcon}"/> + Text="Available Actions"/> + TextWrapping="Wrap"/> - + - + @@ -82,7 +86,7 @@ + Header="Action Name"/> @@ -94,7 +98,7 @@ FontSize="14" Opacity="0.6" Text="All actions are currently enabled" - Visibility="Collapsed" /> + Visibility="Collapsed"/> @@ -113,8 +117,10 @@ Click="AddButton_Click" ToolTip="Add selected action to enabled list"> - - + + - - + + - + - - + + - - + + @@ -164,12 +176,12 @@ Margin="0,0,0,8" FontSize="18" FontWeight="SemiBold" - Text="Enabled Actions" /> + Text="Enabled Actions"/> + TextWrapping="Wrap"/> - + - + @@ -195,14 +209,16 @@ - + Header="Action Name"/> + - - - - + + + + @@ -217,12 +233,130 @@ x:Name="StayOpenToggle" Margin="0,16,0,0" Content="Keep menu open after clicking an action" - ToolTip="When enabled, the post-grab menu stays open after clicking an action, allowing multiple actions to be selected" /> + ToolTip="When enabled, the post-grab menu stays open after clicking an action, allowing multiple actions to be selected"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ToolTip="Reset to default post-grab actions"/> + ToolTip="Save changes and close"/> + ToolTip="Close without saving"/> diff --git a/Text-Grab/Controls/PostGrabActionEditor.xaml.cs b/Text-Grab/Controls/PostGrabActionEditor.xaml.cs index a050266a..c82d375d 100644 --- a/Text-Grab/Controls/PostGrabActionEditor.xaml.cs +++ b/Text-Grab/Controls/PostGrabActionEditor.xaml.cs @@ -7,6 +7,7 @@ using System.Windows.Data; using Text_Grab.Models; using Text_Grab.Utilities; +using Text_Grab.Views; using Wpf.Ui.Controls; namespace Text_Grab.Controls; @@ -73,6 +74,9 @@ public PostGrabActionEditor() // Update empty state visibility UpdateEmptyStateVisibility(); + + // Load templates + LoadTemplates(); } #endregion Constructors @@ -199,5 +203,70 @@ private void UpdateEmptyStateVisibility() } } + private void LoadTemplates() + { + List templates = GrabTemplateManager.GetAllTemplates(); + TemplatesListBox.ItemsSource = templates; + UpdateTemplateEmptyState(templates.Count); + } + + private void UpdateTemplateEmptyState(int count) + { + bool hasTemplates = count > 0; + TemplatesListBox.Visibility = hasTemplates ? Visibility.Visible : Visibility.Collapsed; + NoTemplatesText.Visibility = hasTemplates ? Visibility.Collapsed : Visibility.Visible; + } + + private void OpenGrabFrameButton_Click(object sender, RoutedEventArgs e) + { + GrabFrame grabFrame = new(); + grabFrame.Closed += (_, _) => RefreshTemplatesAndActions(); + grabFrame.Show(); + grabFrame.Activate(); + } + + private void DeleteTemplateButton_Click(object sender, RoutedEventArgs e) + { + if (TemplatesListBox.SelectedItem is not GrabTemplate selected) + return; + + System.Windows.MessageBoxResult result = System.Windows.MessageBox.Show( + $"Delete template '{selected.Name}'?", + "Delete Template", + System.Windows.MessageBoxButton.YesNo, + System.Windows.MessageBoxImage.Question); + + if (result != System.Windows.MessageBoxResult.Yes) + return; + + GrabTemplateManager.DeleteTemplate(selected.Id); + + // Also remove any enabled action tied to this template + ButtonInfo? toRemove = EnabledActions.FirstOrDefault(a => a.TemplateId == selected.Id); + if (toRemove is not null) + EnabledActions.Remove(toRemove); + + RefreshTemplatesAndActions(); + } + + private void RefreshTemplatesAndActions() + { + LoadTemplates(); + + // Rebuild available actions list to include/exclude updated templates + List allActions = PostGrabActionManager.GetAvailablePostGrabActions(); + List enabledIds = [.. EnabledActions]; + + AvailableActions.Clear(); + foreach (ButtonInfo action in allActions + .Where(a => !enabledIds.Any(e => e.ButtonText == a.ButtonText && e.TemplateId == a.TemplateId)) + .OrderBy(a => a.OrderNumber)) + { + AvailableActions.Add(action); + } + + UpdateEmptyStateVisibility(); + } + #endregion Methods } diff --git a/Text-Grab/Pages/FullscreenGrabSettings.xaml b/Text-Grab/Pages/FullscreenGrabSettings.xaml index 54a6c2a2..57d04d05 100644 --- a/Text-Grab/Pages/FullscreenGrabSettings.xaml +++ b/Text-Grab/Pages/FullscreenGrabSettings.xaml @@ -10,18 +10,21 @@ Loaded="Page_Loaded" mc:Ignorable="d"> - - + + + Text="Defaults"/> - + + Text="Single Line outputs captures as a single line (same as pressing S). Table mode requires Windows OCR/AI languages."/> + Content="Send output to Edit Text Window by default"/> + Text="Automatically route capture text into the Edit Text Window (same as pressing E)."/> + Content="Dim screen with translucent overlay while selecting"/> + Text="Controls the transparent overlay shown during Fullscreen Grab selection. Turn off to disable screen dimming."/> + Text="Post-capture insert"/> + Content="Insert captured text into the focused app after closing Fullscreen Grab"/> + Text="Insert delay (seconds):"/> + ValueChanged="InsertDelaySlider_ValueChanged"/> + Style="{StaticResource TextBodyNormal}"/> + Text="How long to wait before simulating paste (Ctrl+V)."/> + Text="Post-capture actions"/> + Text="Configure which actions are performed automatically after text is captured (Fix GUIDs, Trim lines, Remove duplicates, etc.)."/> @@ -470,7 +487,8 @@ Grid.Row="1" ClipToBounds="True"> - + @@ -482,7 +500,7 @@ HorizontalAlignment="Center" VerticalAlignment="Center" Opacity="0" - Stretch="Uniform" /> + Stretch="Uniform"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 0a4df295d8393d22ac958a62b21e079ae4fb8816 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 1 Mar 2026 20:36:27 -0600 Subject: [PATCH 031/109] Add chip picker for template output editing in GrabFrame Introduce InlinePickerRichTextBox for editing Grab Template output, enabling users to insert region chips via a picker UI. Hide the old plain TextBox and update all related logic to use the new control, including serialization and deserialization of template output. Ensure region chips are kept in sync with word borders, and improve file handling for template images. Also includes XAML cleanup and minor refactoring for maintainability. --- Text-Grab/Utilities/GrabTemplateManager.cs | 13 +- Text-Grab/Views/GrabFrame.xaml | 343 ++++++++++----------- Text-Grab/Views/GrabFrame.xaml.cs | 40 ++- 3 files changed, 207 insertions(+), 189 deletions(-) diff --git a/Text-Grab/Utilities/GrabTemplateManager.cs b/Text-Grab/Utilities/GrabTemplateManager.cs index 03d9126b..70e99ef2 100644 --- a/Text-Grab/Utilities/GrabTemplateManager.cs +++ b/Text-Grab/Utilities/GrabTemplateManager.cs @@ -67,15 +67,22 @@ private static string GetTemplatesFilePath() if (!Directory.Exists(folder)) Directory.CreateDirectory(folder); - string safeName = Regex.Replace(templateName.ToLowerInvariant(), @"[^\w]+", "-").Trim('-'); + string safeName = templateName.ReplaceReservedCharacters(); string shortId = templateId.Length >= 8 ? templateId[..8] : templateId; string filePath = Path.Combine(folder, $"{safeName}_{shortId}.png"); + // Write to a temp file first so the encoder never contends with WPF's + // read lock on filePath (held when BitmapImage was loaded without OnLoad). + string tempPath = Path.Combine(folder, $"{Guid.NewGuid():N}.tmp"); + PngBitmapEncoder encoder = new(); encoder.Frames.Add(BitmapFrame.Create(imageSource)); - using FileStream fs = new(filePath, FileMode.Create, FileAccess.Write); - encoder.Save(fs); + using (FileStream fs = new(tempPath, FileMode.Create, FileAccess.Write, FileShare.None)) + encoder.Save(fs); + // Atomically replace the destination; succeeds even when the target file + // is open for reading by another process (e.g. WPF's BitmapImage). + File.Move(tempPath, filePath, overwrite: true); return filePath; } catch (Exception ex) diff --git a/Text-Grab/Views/GrabFrame.xaml b/Text-Grab/Views/GrabFrame.xaml index 3eb7a778..6f64338b 100644 --- a/Text-Grab/Views/GrabFrame.xaml +++ b/Text-Grab/Views/GrabFrame.xaml @@ -37,21 +37,14 @@ WindowStyle="None" mc:Ignorable="d"> - + - @@ -59,31 +52,31 @@ + Executed="PasteExecuted" /> + Executed="UndoExecuted" /> + Executed="RedoExecuted" /> + Executed="DeleteWordBordersExecuted" /> + Executed="MergeWordBordersExecuted" /> + Executed="GrabExecuted" /> + Executed="GrabTrimExecuted" /> @@ -91,7 +84,7 @@ CaptionHeight="32" CornerRadius="18,18,2,18" GlassFrameThickness="0" - ResizeBorderThickness="5"/> + ResizeBorderThickness="5" /> - - - - + + + + - - - - - + + + + + - + + IsChecked="True" /> + IsChecked="True" /> + Unchecked="ReadBarcodesMenuItem_Checked" /> - - + IsChecked="{Binding Topmost, ElementName=GrabFrameWindow, Mode=TwoWay}" /> + + - + IsCheckable="True" /> + + Tag="English" /> + Tag="Spanish" /> + Tag="French" /> + Tag="German" /> + Tag="Italian" /> + Tag="Portuguese" /> + Tag="Russian" /> + Tag="Japanese" /> + Tag="Chinese (Simplified)" /> + Tag="Korean" /> + Tag="Arabic" /> + Tag="Hindi" /> - + + InputGestureText="Ctrl + Shift + V" /> - - + InputGestureText="Ctrl + O" /> + + + InputGestureText="Ctrl + G" /> - + InputGestureText="Ctrl + Shift + G" /> + + Header="Text Grab Settings" /> + InputGestureText="Alt + F4" /> + InputGestureText="Ctrl + Y" /> + InputGestureText="Ctrl + Z" /> + InputGestureText="Ctrl + A" /> - + IsCheckable="True" /> + + Header="Invert Colors" /> + Header="Increase Contrast (Sigmoid)" /> + Header="Brighten" /> + Header="Darken" /> + Header="Grayscale" /> + InputGestureText="(Ctrl + R)" /> + Unchecked="AspectRationMI_Checked" /> + IsChecked="{Binding IsChecked, ElementName=FreezeToggleButton, Mode=TwoWay}" /> + IsChecked="{Binding IsChecked, ElementName=TableToggleButton, Mode=TwoWay}" /> + IsChecked="{Binding IsChecked, ElementName=EditToggleButton, Mode=TwoWay}" /> - + IsChecked="{Binding IsChecked, ElementName=EditTextToggleButton, Mode=TwoWay}" /> + + InputGestureText="Ctrl + I" /> + InputGestureText="Ctrl + M" /> - + InputGestureText="Del" /> + + Tag="None" /> + Tag="Resize" /> - + Tag="Zoom" /> + + IsCheckable="True" /> + Header="_Contact The Developer..." /> + Header="_Rate and Review..." /> + Header="_Feedback..." /> + Header="_About" /> @@ -424,10 +412,8 @@ @@ -442,7 +428,7 @@ Padding="8,2" HorizontalAlignment="Right" Background="{ui:ThemeResource ApplicationBackgroundBrush}" - WindowChrome.IsHitTestVisibleInChrome="True"/> + WindowChrome.IsHitTestVisibleInChrome="True" /> - + @@ -487,8 +471,7 @@ Grid.Row="1" ClipToBounds="True"> - + @@ -500,7 +483,7 @@ HorizontalAlignment="Center" VerticalAlignment="Center" Opacity="0" - Stretch="Uniform"/> + Stretch="Uniform" /> + InputGestureText="Ctrl + Shift + V" /> - + InputGestureText="Ctrl + O" /> + - + Header="Copy Text" /> + + Unchecked="AspectRationMI_Checked" /> - + InputGestureText="F" /> + + Header="Try To Make _Numbers" /> + Header="Try To Make _Letters" /> + InputGestureText="Ctrl + M" /> + InputGestureText="Del" /> @@ -577,21 +560,21 @@ Direction="270" Opacity="0.3" ShadowDepth="2" - Color="Black"/> + Color="Black" /> - - - - + + + + + Symbol="LocalLanguage24" /> + Text="Translating..." /> + Value="0" /> + Text="0/0" /> @@ -648,64 +631,69 @@ Visibility="Collapsed"> - - - - - - + + + + + + + Text="Template Name:" /> + VerticalContentAlignment="Center" /> + Text="Output (type { to pick):" + ToolTip="Type { to insert a region chip. Plain text is also supported." /> + ToolTip="Use {1}, {2}, etc. for region values." + Visibility="Collapsed" /> + - - + + + VerticalScrollBarVisibility="Auto" /> - - - - + + + + + IsCheckable="True" /> + IsCheckable="True" /> + IsCheckable="True" /> + IsCheckable="True" /> + IsCheckable="True" /> + IsCheckable="True" /> + IsCheckable="True" /> @@ -715,31 +698,31 @@ + IsCheckable="True" /> + IsCheckable="True" /> + IsCheckable="True" /> + IsCheckable="True" /> + IsCheckable="True" /> + IsCheckable="True" /> + IsCheckable="True" /> @@ -747,7 +730,7 @@ x:Name="CalcAggregateStatusText" FontSize="12" Foreground="{DynamicResource TextFillColorSecondaryBrush}" - Text=""/> + Text="" /> + Symbol="Copy24" /> @@ -775,8 +758,7 @@ BorderThickness="0" Click="CalcCopyAllButton_Click" ToolTip="Copy All Results"> - + @@ -810,62 +791,55 @@ Margin="0,0,0,8" FontSize="16" FontWeight="Bold" - Text="Calculation Pane"/> - - + Text="Calculation Pane" /> + + - - + Text="Features:" /> + + - - + + - - + + - - + + - - + + - - + + + Text="Examples:" /> - - - - - + + + + + - - - + + + + TextWrapping="Wrap" /> - + - + @@ -901,7 +875,7 @@ Background="{DynamicResource SolidBackgroundFillColorBaseBrush}" MouseDoubleClick="TextBoxSplitter_MouseDoubleClick" ResizeDirection="Auto" - ShowsPreview="True"/> + ShowsPreview="True" /> - + - - + + @@ -937,13 +908,13 @@ Grid.Row="2" Background="Transparent"> - - + + + Orientation="Horizontal" /> + Text="0 matches" /> @@ -1009,17 +980,17 @@ + Text="Regex: " /> + Header="Save Pattern" /> + Header="Explain Pattern" /> @@ -1037,12 +1008,12 @@ x:Name="CharDetailsButtonText" FontFamily="Cascadia Mono" FontSize="12" - Text="U+0000"/> + Text="U+0000" /> + Text="Ln 1, Col 0" /> - + - + @@ -1080,14 +1049,14 @@ x:Name="ProgressRing" Width="60" Height="60" - IsIndeterminate="True"/> + IsIndeterminate="True" /> + Text="Working..." /> diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index c63269e8..f2ab0375 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -2372,13 +2372,12 @@ private void UpdateTemplateBadges() private void UpdateTemplatePickerItems() { List sorted = [.. wordBorders.OrderBy(w => w.Top).ThenBy(w => w.Left)]; - TemplateOutputBox.ItemsSource = sorted + TemplateOutputBox.ItemsSource = [.. sorted .Select((wb, i) => { string label = string.IsNullOrWhiteSpace(wb.Word) ? $"Region {i + 1}" : wb.Word; return new InlinePickerItem(label, $"{{{i + 1}}}"); - }) - .ToList(); + })]; } private void TableToggleButton_Click(object? sender = null, RoutedEventArgs? e = null) @@ -2395,10 +2394,10 @@ private async Task TryLoadImageFromPath(string path) ResetGrabFrame(); await Task.Delay(300); BitmapImage droppedImage = new(); - droppedImage.BeginInit(); - droppedImage.UriSource = fileURI; - droppedImage.CacheOption = BitmapCacheOption.OnLoad; // decode fully into memory and release the file handle - System.Drawing.RotateFlipType rotateFlipType = ImageMethods.GetRotateFlipType(path); + droppedImage.BeginInit(); + droppedImage.UriSource = fileURI; + droppedImage.CacheOption = BitmapCacheOption.OnLoad; // decode fully into memory and release the file handle + System.Drawing.RotateFlipType rotateFlipType = ImageMethods.GetRotateFlipType(path); ImageMethods.RotateImage(droppedImage, rotateFlipType); droppedImage.EndInit(); frameContentImageSource = droppedImage; From 44116597d2ec18329839a818a6ad918a4b95a360 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 1 Mar 2026 21:23:00 -0600 Subject: [PATCH 035/109] Add Microsoft.WindowsAppSDK.WinUI package reference Added Microsoft.WindowsAppSDK.WinUI version 1.8.260204000 to the project file to enable or support WinUI features. No other changes were made. --- Text-Grab/Text-Grab.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/Text-Grab/Text-Grab.csproj b/Text-Grab/Text-Grab.csproj index 4c1bf1c7..23ed5387 100644 --- a/Text-Grab/Text-Grab.csproj +++ b/Text-Grab/Text-Grab.csproj @@ -61,6 +61,7 @@ + From 7c8c8918edc1b9c6e3b5e316bd238a56d92d35fa Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 1 Mar 2026 23:55:56 -0600 Subject: [PATCH 036/109] Improve GrabFrame content area calculation and DPI accuracy Refactor content area and image rect calculations to use WPF's layout engine and PointToScreen, eliminating hardcoded offsets and improving accuracy across DPI settings. Update border and resize settings for better appearance and usability. These changes ensure robust, layout-independent screen coordinate handling. --- Text-Grab/Utilities/ImageMethods.cs | 16 +++++----- Text-Grab/Views/GrabFrame.xaml | 6 ++-- Text-Grab/Views/GrabFrame.xaml.cs | 46 ++++++++++++++++++++--------- 3 files changed, 43 insertions(+), 25 deletions(-) diff --git a/Text-Grab/Utilities/ImageMethods.cs b/Text-Grab/Utilities/ImageMethods.cs index ae43afd5..e4a908c0 100644 --- a/Text-Grab/Utilities/ImageMethods.cs +++ b/Text-Grab/Utilities/ImageMethods.cs @@ -117,16 +117,16 @@ public static Bitmap GetWindowsBoundsBitmap(Window passedWindow) { Rect imageRect = grabFrame.GetImageContentRect(); - int borderThickness = 2; - int titleBarHeight = 32; - int bottomBarHeight = 42; - if (imageRect == Rect.Empty) { - thisCorrectedLeft = (int)((absPosPoint.X + borderThickness) * dpi.DpiScaleX); - thisCorrectedTop = (int)((absPosPoint.Y + (titleBarHeight + borderThickness)) * dpi.DpiScaleY); - windowWidth -= (int)((2 * borderThickness) * dpi.DpiScaleX); - windowHeight -= (int)((titleBarHeight + bottomBarHeight + (2 * borderThickness)) * dpi.DpiScaleY); + // Ask WPF's layout engine for the exact physical-pixel bounds of the + // transparent content area. This is always correct regardless of DPI, + // border thickness, or title/bottom bar heights. + Rectangle contentRect = grabFrame.GetContentAreaScreenRect(); + thisCorrectedLeft = contentRect.X; + thisCorrectedTop = contentRect.Y; + windowWidth = contentRect.Width; + windowHeight = contentRect.Height; } else { diff --git a/Text-Grab/Views/GrabFrame.xaml b/Text-Grab/Views/GrabFrame.xaml index ec49cd0e..19f3a283 100644 --- a/Text-Grab/Views/GrabFrame.xaml +++ b/Text-Grab/Views/GrabFrame.xaml @@ -17,8 +17,8 @@ AllowDrop="True" AllowsTransparency="True" Background="Transparent" - BorderBrush="Gray" - BorderThickness="0.2" + BorderBrush="{ui:ThemeResource ApplicationBackgroundBrush}" + BorderThickness="1" Closed="Window_Closed" Closing="GrabFrameWindow_Closing" Deactivated="GrabFrameWindow_Deactivated" @@ -84,7 +84,7 @@ CaptionHeight="32" CornerRadius="18,18,2,18" GlassFrameThickness="0" - ResizeBorderThickness="5" /> + ResizeBorderThickness="8" /> + /// Returns the physical-pixel screen rectangle that exactly covers the + /// transparent content area (RectanglesBorder, Row 1 of the grid). + /// Uses PointToScreen so it is always accurate regardless of border + /// thickness, DPI, or future layout changes. + /// + internal System.Drawing.Rectangle GetContentAreaScreenRect() + { + DpiScale dpi = VisualTreeHelper.GetDpi(this); + Point topLeft = RectanglesBorder.PointToScreen(new Point(0, 0)); + return new System.Drawing.Rectangle( + (int)topLeft.X, + (int)topLeft.Y, + (int)(RectanglesBorder.ActualWidth * dpi.DpiScaleX), + (int)(RectanglesBorder.ActualHeight * dpi.DpiScaleY)); + } + public Rect GetImageContentRect() { // This is a WIP to try to remove the gray letterboxes on either @@ -1070,31 +1087,32 @@ private async Task DrawRectanglesAroundWords(string searchWord = "") RectanglesCanvas.Children.Clear(); wordBorders.Clear(); - Point windowPosition = this.GetAbsolutePosition(); DpiScale dpi = VisualTreeHelper.GetDpi(this); double canvasScale = CanvasViewBox.GetHorizontalScaleFactor(); - Point rectanglesPosition = RectanglesCanvas.TransformToAncestor(this) - .Transform(new Point(0, 0)); - if (double.IsNaN(canvasScale)) canvasScale = 1; - double ContentWidth = RectanglesCanvas.RenderSize.Width; - double ContentHeight = RectanglesCanvas.RenderSize.Height; + double contentWidth = RectanglesCanvas.RenderSize.Width; + double contentHeight = RectanglesCanvas.RenderSize.Height; - if (ContentWidth == 4 || ContentHeight == 2) + // When the canvas hasn't been measured yet (Viewbox content is still + // at its minimum size), fall back to the containing border's dimensions. + if (contentWidth <= 4 || contentHeight <= 2) { - ContentWidth = RectanglesBorder.RenderSize.Width; - ContentHeight = RectanglesBorder.RenderSize.Height; - rectanglesPosition = new(-2, 32); + contentWidth = RectanglesBorder.ActualWidth; + contentHeight = RectanglesBorder.ActualHeight; + canvasScale = 1; } + // PointToScreen gives the exact physical-pixel origin of the canvas + // regardless of border thickness, DPI, or layout changes. + Point scanTopLeft = RectanglesCanvas.PointToScreen(new Point(0, 0)); System.Drawing.Rectangle rectCanvasSize = new() { - Width = (int)(ContentWidth * dpi.DpiScaleX * canvasScale), - Height = (int)(ContentHeight * dpi.DpiScaleY * canvasScale), - X = (int)((windowPosition.X + rectanglesPosition.X) * dpi.DpiScaleX), - Y = (int)((windowPosition.Y + rectanglesPosition.Y) * dpi.DpiScaleY) + X = (int)scanTopLeft.X, + Y = (int)scanTopLeft.Y, + Width = (int)(contentWidth * dpi.DpiScaleX * canvasScale), + Height = (int)(contentHeight * dpi.DpiScaleY * canvasScale), }; if (ocrResultOfWindow is null || ocrResultOfWindow.Lines.Length == 0) From 3cd300126aaa988b343499266cf1e27a040e6f7e Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Mon, 2 Mar 2026 20:02:43 -0600 Subject: [PATCH 037/109] Improve GrabFrame overlay scaling and image cropping accuracy - Fix Viewbox scaling to use both width and height (Uniform) - Crop frozen image to selection for GrabFrame continuity - Add GrabFrame constructor for pre-loaded cropped images - Explicitly size overlay canvas to match image pixels - Rescale word borders from history for accurate overlays - Refine content/non-content area calculations for sizing - Improve background color sampling for word borders - Add robustness checks for invalid/unmeasured sizes - Slightly increase GrabFrame title bar height for UI polish These changes resolve overlay misalignment and scaling issues, ensuring accurate OCR word border placement and a consistent user experience when grabbing, freezing, and editing regions. --- Text-Grab/Extensions/ControlExtensions.cs | 27 +- Text-Grab/Views/FullscreenGrab.xaml | 4 +- Text-Grab/Views/FullscreenGrab.xaml.cs | 34 ++- Text-Grab/Views/GrabFrame.xaml | 2 +- Text-Grab/Views/GrabFrame.xaml.cs | 316 ++++++++++++++++++---- 5 files changed, 315 insertions(+), 68 deletions(-) diff --git a/Text-Grab/Extensions/ControlExtensions.cs b/Text-Grab/Extensions/ControlExtensions.cs index 7ccfe990..4e3a32ab 100644 --- a/Text-Grab/Extensions/ControlExtensions.cs +++ b/Text-Grab/Extensions/ControlExtensions.cs @@ -1,3 +1,4 @@ +using System; using System.Windows; using System.Windows.Controls; @@ -14,9 +15,33 @@ public static double GetHorizontalScaleFactor(this Viewbox viewbox) return 1.0; double outsideWidth = viewbox.ActualWidth; + double outsideHeight = viewbox.ActualHeight; double insideWidth = childElement.ActualWidth; + double insideHeight = childElement.ActualHeight; - return outsideWidth / insideWidth; + if (!double.IsFinite(outsideWidth) || !double.IsFinite(insideWidth) + || outsideWidth <= 0 || insideWidth <= 4) + { + return 1.0; + } + + // A Viewbox with Stretch="Uniform" applies min(width_ratio, height_ratio) so that + // the content fits in both dimensions. Using only the width ratio produces the wrong + // scale when the image is height-limited (taller relative to the window than it is + // wide), which causes OCR word borders to be placed at incorrect canvas positions. + double scale = outsideWidth / insideWidth; + + if (double.IsFinite(outsideHeight) && double.IsFinite(insideHeight) + && outsideHeight > 0 && insideHeight > 4) + { + double scaleY = outsideHeight / insideHeight; + scale = Math.Min(scale, scaleY); + } + + if (!double.IsFinite(scale) || scale <= 0) + return 1.0; + + return scale; } public static Rect GetAbsolutePlacement(this FrameworkElement element, bool relativeToScreen = false) diff --git a/Text-Grab/Views/FullscreenGrab.xaml b/Text-Grab/Views/FullscreenGrab.xaml index e6fedeb3..231ddc76 100644 --- a/Text-Grab/Views/FullscreenGrab.xaml +++ b/Text-Grab/Views/FullscreenGrab.xaml @@ -112,9 +112,7 @@ - + 0 && cropH > 0) + { + CroppedBitmap croppedBitmap = new(backgroundBitmap, new Int32Rect(cropX, cropY, cropW, cropH)); + croppedBitmap.Freeze(); + grabFrame = new GrabFrame(croppedBitmap); + } + else + { + grabFrame = new GrabFrame(); + } + } + else { - Left = posLeft, - Top = posTop, - }; + grabFrame = new GrabFrame(); + } + + grabFrame.Left = posLeft; + grabFrame.Top = posTop; grabFrame.Left -= (2 / dpi.PixelsPerDip); grabFrame.Top -= (48 / dpi.PixelsPerDip); diff --git a/Text-Grab/Views/GrabFrame.xaml b/Text-Grab/Views/GrabFrame.xaml index 19f3a283..2ec53219 100644 --- a/Text-Grab/Views/GrabFrame.xaml +++ b/Text-Grab/Views/GrabFrame.xaml @@ -92,7 +92,7 @@ ClipToBounds="True"> - + diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index 4b9b8a4c..6f6bcad6 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -138,6 +138,27 @@ public GrabFrame(string imagePath) Loaded += async (s, e) => await TryLoadImageFromPath(absolutePath); } + /// + /// Creates a GrabFrame pre-loaded with a frozen image cropped from a Fullscreen Grab selection. + /// The frame opens in freeze mode showing the provided bitmap and immediately runs OCR. + /// + /// The cropped bitmap to display as the initial frozen background. + public GrabFrame(BitmapSource frozenImage) + { + StandardInitialize(); + + ShouldSaveOnClose = true; + frameContentImageSource = frozenImage; + hasLoadedImageSource = true; + + Loaded += (s, e) => + { + FreezeToggleButton.IsChecked = true; + FreezeGrabFrame(); + reDrawTimer.Start(); + }; + } + /// /// Opens GrabFrame in template editing mode with existing regions pre-loaded. /// @@ -231,11 +252,30 @@ private async Task LoadContentFromHistory(HistoryInfo history) return; } + history.ImageContent = bgBitmap; frameContentImageSource = ImageMethods.BitmapToImageSource(bgBitmap); hasLoadedImageSource = true; GrabFrameImage.Source = frameContentImageSource; FreezeGrabFrame(); + if (history.PositionRect != Rect.Empty) + { + Left = history.PositionRect.Left; + Top = history.PositionRect.Top; + + if (history.SourceMode == TextGrabMode.Fullscreen) + { + Size nonContentSize = GetGrabFrameNonContentSize(); + Width = history.PositionRect.Width + nonContentSize.Width; + Height = history.PositionRect.Height + nonContentSize.Height; + } + else + { + Width = history.PositionRect.Width; + Height = history.PositionRect.Height; + } + } + List? wbInfoList = null; if (!string.IsNullOrWhiteSpace(history.WordBorderInfoJson)) @@ -243,6 +283,8 @@ private async Task LoadContentFromHistory(HistoryInfo history) if (wbInfoList is not null && wbInfoList.Count > 0) { + ScaleHistoryWordBordersToCanvas(history, wbInfoList); + foreach (WordBorderInfo info in wbInfoList) { WordBorder wb = new(info) @@ -263,26 +305,95 @@ private async Task LoadContentFromHistory(HistoryInfo history) ShouldSaveOnClose = true; } - if (history.PositionRect != Rect.Empty) + TableToggleButton.IsChecked = history.IsTable; + + UpdateFrameText(); + } + + private Size GetGrabFrameNonContentSize() + { + const double defaultNonContentWidth = 4; + const double defaultNonContentHeight = 74; + + UpdateLayout(); + + if (ActualWidth <= 1 || ActualHeight <= 1 + || RectanglesBorder.ActualWidth <= 1 || RectanglesBorder.ActualHeight <= 1) { - Left = history.PositionRect.Left; - Top = history.PositionRect.Top; - Height = history.PositionRect.Height; - Width = history.PositionRect.Width; + return new Size(defaultNonContentWidth, defaultNonContentHeight); + } - if (history.SourceMode == TextGrabMode.Fullscreen) - { - int borderThickness = 2; - int titleBarHeight = 32; - int bottomBarHeight = 42; - Height += (titleBarHeight + bottomBarHeight); - Width += (2 * borderThickness); - } + double nonContentWidth = ActualWidth - RectanglesBorder.ActualWidth; + double nonContentHeight = ActualHeight - RectanglesBorder.ActualHeight; + + if (!double.IsFinite(nonContentWidth) || nonContentWidth < 0 || nonContentWidth > 100) + nonContentWidth = defaultNonContentWidth; + + if (!double.IsFinite(nonContentHeight) || nonContentHeight < 0 || nonContentHeight > 200) + nonContentHeight = defaultNonContentHeight; + + return new Size(nonContentWidth, nonContentHeight); + } + + private void ScaleHistoryWordBordersToCanvas(HistoryInfo history, List wbInfoList) + { + if (wbInfoList.Count == 0 || RectanglesCanvas.Width <= 0 || RectanglesCanvas.Height <= 0) + return; + + Size savedContentSize = GetSavedHistoryContentSize(history); + if (savedContentSize.Width <= 0 || savedContentSize.Height <= 0) + return; + + double scaleX = RectanglesCanvas.Width / savedContentSize.Width; + double scaleY = RectanglesCanvas.Height / savedContentSize.Height; + if (!double.IsFinite(scaleX) || !double.IsFinite(scaleY) || (scaleX <= 1.05 && scaleY <= 1.05)) + return; + + double maxRight = wbInfoList.Max(info => info.BorderRect.Right); + double maxBottom = wbInfoList.Max(info => info.BorderRect.Bottom); + + // Scale only when saved word borders look like they were captured in + // the old window-content coordinate space rather than image-space. + if (maxRight > savedContentSize.Width * 1.1 || maxBottom > savedContentSize.Height * 1.1) + return; + + foreach (WordBorderInfo info in wbInfoList) + { + Rect borderRect = info.BorderRect; + info.BorderRect = new Rect( + borderRect.Left * scaleX, + borderRect.Top * scaleY, + borderRect.Width * scaleX, + borderRect.Height * scaleY); } + } - TableToggleButton.IsChecked = history.IsTable; + private Size GetSavedHistoryContentSize(HistoryInfo history) + { + if (history.ImageContent is System.Drawing.Bitmap imageContentBitmap + && imageContentBitmap.Width > 0 && imageContentBitmap.Height > 0) + { + return new Size(imageContentBitmap.Width, imageContentBitmap.Height); + } - UpdateFrameText(); + Rect positionRect = history.PositionRect; + if (positionRect == Rect.Empty || positionRect.Width <= 0 || positionRect.Height <= 0) + return new Size(0, 0); + + if (history.SourceMode == TextGrabMode.Fullscreen) + return new Size(positionRect.Width, positionRect.Height); + + Size nonContentSize = GetGrabFrameNonContentSize(); + double contentWidth = positionRect.Width - nonContentSize.Width; + double contentHeight = positionRect.Height - nonContentSize.Height; + + if (!double.IsFinite(contentWidth) || contentWidth <= 0) + contentWidth = positionRect.Width; + + if (!double.IsFinite(contentHeight) || contentHeight <= 0) + contentHeight = positionRect.Height; + + return new Size(contentWidth, contentHeight); } /// @@ -307,17 +418,21 @@ public Rect GetImageContentRect() // This is a WIP to try to remove the gray letterboxes on either // side of the image when zooming it. - Rect imageRect = Rect.Empty; + if (frameContentImageSource is null || !IsLoaded || !RectanglesCanvas.IsLoaded) + return Rect.Empty; - if (frameContentImageSource is null) - return imageRect; + Rect canvasPlacement = RectanglesCanvas.GetAbsolutePlacement(true); + if (canvasPlacement == Rect.Empty) + return Rect.Empty; - imageRect = RectanglesCanvas.GetAbsolutePlacement(true); Size rectCanvasSize = RectanglesCanvas.RenderSize; - imageRect.Width = rectCanvasSize.Width; - imageRect.Height = rectCanvasSize.Height; + if (!double.IsFinite(rectCanvasSize.Width) || !double.IsFinite(rectCanvasSize.Height) + || rectCanvasSize.Width <= 0 || rectCanvasSize.Height <= 0) + { + return canvasPlacement; + } - return imageRect; + return new Rect(canvasPlacement.X, canvasPlacement.Y, rectCanvasSize.Width, rectCanvasSize.Height); } private void StandardInitialize() @@ -1088,33 +1203,14 @@ private async Task DrawRectanglesAroundWords(string searchWord = "") wordBorders.Clear(); DpiScale dpi = VisualTreeHelper.GetDpi(this); - double canvasScale = CanvasViewBox.GetHorizontalScaleFactor(); - if (double.IsNaN(canvasScale)) - canvasScale = 1; - - double contentWidth = RectanglesCanvas.RenderSize.Width; - double contentHeight = RectanglesCanvas.RenderSize.Height; - - // When the canvas hasn't been measured yet (Viewbox content is still - // at its minimum size), fall back to the containing border's dimensions. - if (contentWidth <= 4 || contentHeight <= 2) + System.Drawing.Rectangle rectCanvasSize = GetContentAreaScreenRect(); + if (rectCanvasSize.Width <= 0 || rectCanvasSize.Height <= 0) { - contentWidth = RectanglesBorder.ActualWidth; - contentHeight = RectanglesBorder.ActualHeight; - canvasScale = 1; + isDrawing = false; + reDrawTimer.Start(); + return; } - // PointToScreen gives the exact physical-pixel origin of the canvas - // regardless of border thickness, DPI, or layout changes. - Point scanTopLeft = RectanglesCanvas.PointToScreen(new Point(0, 0)); - System.Drawing.Rectangle rectCanvasSize = new() - { - X = (int)scanTopLeft.X, - Y = (int)scanTopLeft.Y, - Width = (int)(contentWidth * dpi.DpiScaleX * canvasScale), - Height = (int)(contentHeight * dpi.DpiScaleY * canvasScale), - }; - if (ocrResultOfWindow is null || ocrResultOfWindow.Lines.Length == 0) { ILanguage lang = CurrentLanguage ?? LanguageUtilities.GetCurrentInputLanguage(); @@ -1126,13 +1222,22 @@ private async Task DrawRectanglesAroundWords(string searchWord = "") isSpaceJoining = CurrentLanguage!.IsSpaceJoining(); - System.Drawing.Bitmap? bmp = null; + System.Drawing.Bitmap? bmp = Singleton.Instance.CachedBitmap; + bool shouldDisposeBmp = false; - if (frameContentImageSource is BitmapSource bmpImg) + if (bmp is null && frameContentImageSource is BitmapSource bmpImg) + { bmp = ImageMethods.BitmapSourceToBitmap(bmpImg); + shouldDisposeBmp = true; + } int lineNumber = 0; double viewBoxZoomFactor = CanvasViewBox.GetHorizontalScaleFactor(); + if (!double.IsFinite(viewBoxZoomFactor) || viewBoxZoomFactor <= 0 || viewBoxZoomFactor > 4) + viewBoxZoomFactor = 1; + Point canvasOriginInBorder = RectanglesCanvas.TranslatePoint(new Point(0, 0), RectanglesBorder); + double borderToCanvasX = -canvasOriginInBorder.X; + double borderToCanvasY = -canvasOriginInBorder.Y; foreach (IOcrLine ocrLine in ocrResultOfWindow.Lines) { @@ -1145,7 +1250,7 @@ private async Task DrawRectanglesAroundWords(string searchWord = "") SolidColorBrush backgroundBrush = new(Colors.Black); if (bmp is not null) - backgroundBrush = GetBackgroundBrushFromBitmap(ref dpi, windowFrameImageScale, bmp, ref lineRect); + backgroundBrush = GetBackgroundBrushFromOcrBitmap(windowFrameImageScale, bmp, ref lineRect); string ocrText = lineText.ToString(); @@ -1159,8 +1264,8 @@ private async Task DrawRectanglesAroundWords(string searchWord = "") { Width = ((lineRect.Width / (dpi.DpiScaleX * windowFrameImageScale)) + 2) / viewBoxZoomFactor, Height = ((lineRect.Height / (dpi.DpiScaleY * windowFrameImageScale)) + 2) / viewBoxZoomFactor, - Top = (lineRect.Y / (dpi.DpiScaleY * windowFrameImageScale) - 1) / viewBoxZoomFactor, - Left = (lineRect.X / (dpi.DpiScaleX * windowFrameImageScale) - 1) / viewBoxZoomFactor, + Top = ((lineRect.Y / (dpi.DpiScaleY * windowFrameImageScale) - 1) + borderToCanvasY) / viewBoxZoomFactor, + Left = ((lineRect.X / (dpi.DpiScaleX * windowFrameImageScale) - 1) + borderToCanvasX) / viewBoxZoomFactor, Word = ocrText, OwnerGrabFrame = this, LineNumber = lineNumber, @@ -1203,7 +1308,8 @@ private async Task DrawRectanglesAroundWords(string searchWord = "") isDrawing = false; - bmp?.Dispose(); + if (shouldDisposeBmp) + bmp?.Dispose(); reSearchTimer.Start(); // Trigger translation if enabled @@ -1335,6 +1441,8 @@ private void FreezeGrabFrame() GrabFrameImage.Source = frameContentImageSource; } + SyncRectanglesCanvasSizeToImage(); + FreezeToggleButton.IsChecked = true; Topmost = false; Background = new SolidColorBrush(Colors.DimGray); @@ -1342,6 +1450,28 @@ private void FreezeGrabFrame() IsFreezeMode = true; } + private void SyncRectanglesCanvasSizeToImage() + { + if (GrabFrameImage.Source is not BitmapSource source) + return; + + // Keep image and overlay in the same coordinate space (raw image pixels). + double sourceWidth = source.PixelWidth > 0 ? source.PixelWidth : source.Width; + double sourceHeight = source.PixelHeight > 0 ? source.PixelHeight : source.Height; + + if (double.IsFinite(sourceWidth) && sourceWidth > 0) + { + GrabFrameImage.Width = sourceWidth; + RectanglesCanvas.Width = sourceWidth; + } + + if (double.IsFinite(sourceHeight) && sourceHeight > 0) + { + GrabFrameImage.Height = sourceHeight; + RectanglesCanvas.Height = sourceHeight; + } + } + private async void FreezeMI_Click(object sender, RoutedEventArgs e) { if (IsFreezeMode) @@ -1371,6 +1501,54 @@ private void FreezeToggleButton_Click(object? sender = null, RoutedEventArgs? e UnfreezeGrabFrame(); } + private static SolidColorBrush GetBackgroundBrushFromOcrBitmap(double scale, System.Drawing.Bitmap bmp, ref Windows.Foundation.Rect lineRect) + { + if (!double.IsFinite(scale) || scale <= 0) + scale = 1; + + double boxLeft = lineRect.Left / scale; + double boxTop = lineRect.Top / scale; + double boxRight = lineRect.Right / scale; + double boxBottom = lineRect.Bottom / scale; + double boxWidth = Math.Max(0, boxRight - boxLeft); + double boxHeight = Math.Max(0, boxBottom - boxTop); + double insetX = Math.Min(boxWidth / 2, Math.Max(1, boxWidth * 0.12)); + double insetY = Math.Min(boxHeight / 2, Math.Max(1, boxHeight * 0.12)); + + int pxLeft = Math.Clamp((int)(boxLeft + insetX), 0, bmp.Width - 1); + int pxTop = Math.Clamp((int)(boxTop + insetY), 0, bmp.Height - 1); + int pxRight = Math.Clamp((int)(boxRight - insetX), 0, bmp.Width - 1); + int pxBottom = Math.Clamp((int)(boxBottom - insetY), 0, bmp.Height - 1); + + if (pxRight < pxLeft) + pxRight = pxLeft; + + if (pxBottom < pxTop) + pxBottom = pxTop; + + System.Drawing.Color pxColorLeftTop = bmp.GetPixel(pxLeft, pxTop); + System.Drawing.Color pxColorRightTop = bmp.GetPixel(pxRight, pxTop); + System.Drawing.Color pxColorRightBottom = bmp.GetPixel(pxRight, pxBottom); + System.Drawing.Color pxColorLeftBottom = bmp.GetPixel(pxLeft, pxBottom); + + List mediaColorList = + [ + ColorHelper.MediaColorFromDrawingColor(pxColorLeftTop), + ColorHelper.MediaColorFromDrawingColor(pxColorRightTop), + ColorHelper.MediaColorFromDrawingColor(pxColorRightBottom), + ColorHelper.MediaColorFromDrawingColor(pxColorLeftBottom), + ]; + + Color? mostCommonColor = mediaColorList.GroupBy(c => c) + .OrderBy(g => g.Count()) + .LastOrDefault()?.Key; + + if (mostCommonColor is not null) + return new SolidColorBrush(mostCommonColor.Value); + + return ColorHelper.SolidColorBrushFromDrawingColor(pxColorLeftTop); + } + private SolidColorBrush GetBackgroundBrushFromBitmap(ref DpiScale dpi, double scale, System.Drawing.Bitmap bmp, ref Windows.Foundation.Rect lineRect) { SolidColorBrush backgroundBrush = new(Colors.Black); @@ -1385,10 +1563,26 @@ private SolidColorBrush GetBackgroundBrushFromBitmap(ref DpiScale dpi, double sc double rightFraction = boxRight / RectanglesCanvas.ActualWidth; double bottomFraction = boxBottom / RectanglesCanvas.ActualHeight; - int pxLeft = Math.Clamp((int)(leftFraction * bmp.Width) - 1, 0, bmp.Width - 1); - int pxTop = Math.Clamp((int)(topFraction * bmp.Height) - 2, 0, bmp.Height - 1); - int pxRight = Math.Clamp((int)(rightFraction * bmp.Width) + 1, 0, bmp.Width - 1); - int pxBottom = Math.Clamp((int)(bottomFraction * bmp.Height) + 1, 0, bmp.Height - 1); + int rawLeft = Math.Clamp((int)(leftFraction * bmp.Width), 0, bmp.Width - 1); + int rawTop = Math.Clamp((int)(topFraction * bmp.Height), 0, bmp.Height - 1); + int rawRight = Math.Clamp((int)(rightFraction * bmp.Width), 0, bmp.Width - 1); + int rawBottom = Math.Clamp((int)(bottomFraction * bmp.Height), 0, bmp.Height - 1); + + int spanX = Math.Max(0, rawRight - rawLeft); + int spanY = Math.Max(0, rawBottom - rawTop); + int insetX = Math.Min(spanX / 2, Math.Max(1, spanX / 8)); + int insetY = Math.Min(spanY / 2, Math.Max(1, spanY / 8)); + int pxLeft = Math.Clamp(rawLeft + insetX, 0, bmp.Width - 1); + int pxTop = Math.Clamp(rawTop + insetY, 0, bmp.Height - 1); + int pxRight = Math.Clamp(rawRight - insetX, 0, bmp.Width - 1); + int pxBottom = Math.Clamp(rawBottom - insetY, 0, bmp.Height - 1); + + if (pxRight < pxLeft) + pxRight = pxLeft; + + if (pxBottom < pxTop) + pxBottom = pxTop; + System.Drawing.Color pxColorLeftTop = bmp.GetPixel(pxLeft, pxTop); System.Drawing.Color pxColorRightTop = bmp.GetPixel(pxRight, pxTop); System.Drawing.Color pxColorRightBottom = bmp.GetPixel(pxRight, pxBottom); @@ -2018,6 +2212,12 @@ private async void ReDrawTimer_Tick(object? sender, EventArgs? e) reDrawTimer.Stop(); SetRefreshOrOcrFrameBtnVis(); + if (!IsLoaded || RectanglesBorder.ActualWidth <= 1 || RectanglesBorder.ActualHeight <= 1) + { + reDrawTimer.Start(); + return; + } + if (CheckKey(VirtualKeyCodes.LeftButton) || CheckKey(VirtualKeyCodes.MiddleButton)) { reDrawTimer.Start(); @@ -2177,6 +2377,10 @@ private void ResetGrabFrame() MainZoomBorder.Reset(); RectanglesCanvas.RenderTransform = Transform.Identity; + RectanglesCanvas.ClearValue(WidthProperty); + RectanglesCanvas.ClearValue(HeightProperty); + GrabFrameImage.ClearValue(WidthProperty); + GrabFrameImage.ClearValue(HeightProperty); IsOcrValid = false; ocrResultOfWindow = null; From bfe084bc1af0fe803ae003011fc5f9a9c3ec5541 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Tue, 3 Mar 2026 19:53:58 -0600 Subject: [PATCH 038/109] Highlight unused template regions in GrabFrame/preview Add visual dimming for template regions not referenced in the output template, both in the GrabFrame editor and FullscreenGrab preview. WordBorder controls now support a dimmed border state, and region highlighting updates dynamically as the template is edited. Also includes minor XAML cleanup for menu item formatting. This improves clarity when editing templates by making unused regions visually distinct. --- Text-Grab/Controls/NotifyIconWindow.xaml | 4 ++-- Text-Grab/Controls/WordBorder.xaml.cs | 23 ++++++++++++++++++- Text-Grab/Extensions/ControlExtensions.cs | 3 --- Text-Grab/Views/FullscreenGrab.xaml.cs | 6 ++++- Text-Grab/Views/GrabFrame.xaml | 1 + Text-Grab/Views/GrabFrame.xaml.cs | 28 +++++++++++++++++++++++ 6 files changed, 58 insertions(+), 7 deletions(-) diff --git a/Text-Grab/Controls/NotifyIconWindow.xaml b/Text-Grab/Controls/NotifyIconWindow.xaml index 468c4ebd..bd6e13f7 100644 --- a/Text-Grab/Controls/NotifyIconWindow.xaml +++ b/Text-Grab/Controls/NotifyIconWindow.xaml @@ -40,8 +40,8 @@ + Header="Paste in Grab Frame" + IsEnabled="False"> diff --git a/Text-Grab/Controls/WordBorder.xaml.cs b/Text-Grab/Controls/WordBorder.xaml.cs index b4dbda65..ec86d291 100644 --- a/Text-Grab/Controls/WordBorder.xaml.cs +++ b/Text-Grab/Controls/WordBorder.xaml.cs @@ -174,7 +174,28 @@ public string Word public void Deselect() { IsSelected = false; - WordBorderBorder.BorderBrush = new SolidColorBrush(Color.FromArgb(255, 48, 142, 152)); + ApplyTemplateDimBorderBrush(); + } + + private bool _isDimmedForTemplate = false; + + /// + /// Dims the border brush to indicate this region is not referenced in the output template. + /// Call with false to restore the normal border color. + /// + public void SetDimmedForTemplate(bool isDimmed) + { + _isDimmedForTemplate = isDimmed; + if (!IsSelected) + ApplyTemplateDimBorderBrush(); + } + + private void ApplyTemplateDimBorderBrush() + { + byte alpha = _isDimmedForTemplate ? (byte)80 : (byte)255; + SolidColorBrush brush = new(Color.FromArgb(alpha, 48, 142, 152)); + WordBorderBorder.BorderBrush = brush; + MoveResizeBorder.BorderBrush = brush; } public void EnterEdit() diff --git a/Text-Grab/Extensions/ControlExtensions.cs b/Text-Grab/Extensions/ControlExtensions.cs index 4e3a32ab..7174705d 100644 --- a/Text-Grab/Extensions/ControlExtensions.cs +++ b/Text-Grab/Extensions/ControlExtensions.cs @@ -6,9 +6,6 @@ namespace Text_Grab; public static class ControlExtensions { - - - public static double GetHorizontalScaleFactor(this Viewbox viewbox) { if (viewbox.Child is not FrameworkElement childElement) diff --git a/Text-Grab/Views/FullscreenGrab.xaml.cs b/Text-Grab/Views/FullscreenGrab.xaml.cs index 2223713a..48ea0907 100644 --- a/Text-Grab/Views/FullscreenGrab.xaml.cs +++ b/Text-Grab/Views/FullscreenGrab.xaml.cs @@ -426,6 +426,9 @@ private void UpdateTemplateRegionOverlays(double selLeft, double selTop, double Canvas.SetTop(templateOverlayCanvas, selTop); System.Windows.Media.Color borderColor = System.Windows.Media.Color.FromArgb(220, 255, 180, 0); + System.Windows.Media.Color dimBorderColor = System.Windows.Media.Color.FromArgb(80, 255, 180, 0); + + HashSet referencedRegions = [.. template.GetReferencedRegionNumbers()]; foreach (TemplateRegion region in template.Regions) { @@ -437,11 +440,12 @@ private void UpdateTemplateRegionOverlays(double selLeft, double selTop, double if (regionWidth < 1 || regionHeight < 1) continue; + bool isReferenced = referencedRegions.Count == 0 || referencedRegions.Contains(region.RegionNumber); Border regionBorder = new() { Width = regionWidth, Height = regionHeight, - BorderBrush = new SolidColorBrush(borderColor), + BorderBrush = new SolidColorBrush(isReferenced ? borderColor : dimBorderColor), BorderThickness = new Thickness(1.5), }; diff --git a/Text-Grab/Views/GrabFrame.xaml b/Text-Grab/Views/GrabFrame.xaml index 2ec53219..a61972c6 100644 --- a/Text-Grab/Views/GrabFrame.xaml +++ b/Text-Grab/Views/GrabFrame.xaml @@ -660,6 +660,7 @@ VerticalAlignment="Center" AcceptsReturn="True" AcceptsTab="True" + TextChanged="TemplateOutputBox_TextChanged" ToolTip="Type { to open the region picker. Plain text also supported." /> + + + + + + diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index cfaf042d..949000f6 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -68,6 +68,9 @@ public partial class GrabFrame : Window private bool isStaticImageSource = false; private readonly Dictionary movingWordBordersDictionary = []; private IOcrLinesWords? ocrResultOfWindow; + private UiAutomationOverlaySnapshot? frozenUiAutomationSnapshot; + private UiAutomationOverlaySnapshot? liveUiAutomationSnapshot; + private readonly DispatcherTimer frameMessageTimer = new(); private readonly DispatcherTimer reDrawTimer = new(); private readonly DispatcherTimer reSearchTimer = new(); private Side resizingSide = Side.None; @@ -75,6 +78,7 @@ public partial class GrabFrame : Window private Point startingMovingPoint; private readonly UndoRedo UndoRedo = new(); private bool wasAltHeld = false; + private bool isSyncingLanguageSelection = false; private double windowFrameImageScale = 1; private readonly ObservableCollection wordBorders = []; private static readonly Settings DefaultSettings = AppUtilities.TextGrabSettings; @@ -141,10 +145,11 @@ public GrabFrame(string imagePath) /// /// Creates a GrabFrame pre-loaded with a frozen image cropped from a Fullscreen Grab selection. - /// The frame opens in freeze mode showing the provided bitmap and immediately runs OCR. + /// The frame opens in freeze mode showing the provided bitmap and can render either OCR results + /// or a pre-captured UI Automation snapshot, depending on the selected language. /// /// The cropped bitmap to display as the initial frozen background. - public GrabFrame(BitmapSource frozenImage) + public GrabFrame(BitmapSource frozenImage, UiAutomationOverlaySnapshot? uiAutomationSnapshot = null) { StandardInitialize(); @@ -152,6 +157,7 @@ public GrabFrame(BitmapSource frozenImage) frameContentImageSource = frozenImage; hasLoadedImageSource = true; isStaticImageSource = true; + frozenUiAutomationSnapshot = uiAutomationSnapshot; Loaded += (s, e) => { @@ -269,7 +275,10 @@ private async Task LoadContentFromHistory(HistoryInfo history) { FrameText = history.TextContent; currentLanguage = history.OcrLanguage; + SyncLanguageComboBoxSelection(currentLanguage); isStaticImageSource = true; + frozenUiAutomationSnapshot = null; + liveUiAutomationSnapshot = null; string imageName = Path.GetFileName(history.ImagePath); @@ -289,7 +298,14 @@ private async Task LoadContentFromHistory(HistoryInfo history) hasLoadedImageSource = true; GrabFrameImage.Source = frameContentImageSource; FreezeGrabFrame(); - NotifyIfUiAutomationNeedsLiveSource(currentLanguage); + + List? wbInfoList = null; + + if (!string.IsNullOrWhiteSpace(history.WordBorderInfoJson)) + wbInfoList = JsonSerializer.Deserialize>(history.WordBorderInfoJson); + + if (wbInfoList is not { Count: > 0 }) + NotifyIfUiAutomationNeedsLiveSource(currentLanguage); if (history.PositionRect != Rect.Empty) { @@ -309,11 +325,6 @@ private async Task LoadContentFromHistory(HistoryInfo history) } } - List? wbInfoList = null; - - if (!string.IsNullOrWhiteSpace(history.WordBorderInfoJson)) - wbInfoList = JsonSerializer.Deserialize>(history.WordBorderInfoJson); - if (wbInfoList is not null && wbInfoList.Count > 0) { ScaleHistoryWordBordersToCanvas(history, wbInfoList); @@ -487,6 +498,9 @@ private void StandardInitialize() translationTimer.Interval = new(0, 0, 0, 0, 1000); translationTimer.Tick += TranslationTimer_Tick; + frameMessageTimer.Interval = TimeSpan.FromSeconds(4); + frameMessageTimer.Tick += FrameMessageTimer_Tick; + _ = UndoRedo.HasUndoOperations(); _ = UndoRedo.HasRedoOperations(); @@ -496,6 +510,55 @@ private void StandardInitialize() DataContext = this; } + private void FrameMessageTimer_Tick(object? sender, EventArgs e) + { + frameMessageTimer.Stop(); + HideFrameMessage(); + } + + private void HideFrameMessage() + { + FrameMessageBorder.Visibility = Visibility.Collapsed; + FrameMessageTextBlock.Text = string.Empty; + } + + private void ShowFrameMessage(string message) + { + if (string.IsNullOrWhiteSpace(message)) + return; + + FrameMessageTextBlock.Text = message; + FrameMessageBorder.Visibility = Visibility.Visible; + frameMessageTimer.Stop(); + frameMessageTimer.Start(); + } + + private void SyncLanguageComboBoxSelection(ILanguage language) + { + if (LanguagesComboBox.Items.Count == 0) + return; + + List availableLanguages = [.. LanguagesComboBox.Items.OfType()]; + int selectedIndex = CaptureLanguageUtilities.FindPreferredLanguageIndex( + availableLanguages, + language.LanguageTag, + language); + + if (selectedIndex < 0 || LanguagesComboBox.SelectedIndex == selectedIndex) + return; + + isSyncingLanguageSelection = true; + try + { + LanguagesComboBox.SelectedIndex = selectedIndex; + currentLanguage = availableLanguages[selectedIndex]; + } + finally + { + isSyncingLanguageSelection = false; + } + } + #endregion Constructors #region Properties @@ -551,10 +614,7 @@ public static bool CheckKey(VirtualKeyCodes code) public HistoryInfo AsHistoryItem() { - System.Drawing.Bitmap? bitmap = null; - - if (frameContentImageSource is BitmapImage image) - bitmap = ImageMethods.BitmapImageToBitmap(image); + System.Drawing.Bitmap? bitmap = ImageMethods.ImageSourceToBitmap(frameContentImageSource); List wbInfoList = []; @@ -727,6 +787,9 @@ public void GrabFrame_Unloaded(object sender, RoutedEventArgs e) reDrawTimer.Stop(); reDrawTimer.Tick -= ReDrawTimer_Tick; + frameMessageTimer.Stop(); + frameMessageTimer.Tick -= FrameMessageTimer_Tick; + translationTimer.Stop(); translationTimer.Tick -= TranslationTimer_Tick; translationSemaphore.Dispose(); @@ -967,7 +1030,10 @@ private async void AddNewWordBorder(Border selectBorder) rect = new(rect.X + 4, rect.Y, (rect.Width * dpi.DpiScaleX) + 10, rect.Height * dpi.DpiScaleY); // Language language = CurrentLanguage.AsLanguage() ?? LanguageUtilities.GetCurrentInputLanguage().AsLanguage() ?? new Language("en-US"); ILanguage language = CurrentLanguage ?? LanguageUtilities.GetCurrentInputLanguage(); - string ocrText = await OcrUtilities.GetTextFromAbsoluteRectAsync(rect.GetScaleSizeByFraction(viewBoxZoomFactor), language); + string ocrText = await OcrUtilities.GetTextFromAbsoluteRectAsync( + rect.GetScaleSizeByFraction(viewBoxZoomFactor), + language, + GetUiAutomationExcludedHandles()); if (language is not UiAutomationLang && DefaultSettings.CorrectErrors) ocrText = ocrText.TryFixEveryWordLetterNumberErrors(); @@ -1221,7 +1287,78 @@ private void DeleteWordBordersExecuted(object sender, ExecutedRoutedEventArgs? e reSearchTimer.Start(); } - private async Task DrawRectanglesAroundWords(string searchWord = "") + private void ClearRenderedWordBorders() + { + RectanglesCanvas.Children.Clear(); + wordBorders.Clear(); + } + + private IReadOnlyCollection? GetUiAutomationExcludedHandles() + { + IntPtr handle = new System.Windows.Interop.WindowInteropHelper(this).Handle; + return handle == IntPtr.Zero ? null : [handle]; + } + + private (double ViewBoxZoomFactor, double BorderToCanvasX, double BorderToCanvasY) GetOverlayRenderMetrics() + { + double viewBoxZoomFactor = CanvasViewBox.GetHorizontalScaleFactor(); + if (!double.IsFinite(viewBoxZoomFactor) || viewBoxZoomFactor <= 0 || viewBoxZoomFactor > 4) + viewBoxZoomFactor = 1; + + Point canvasOriginInBorder = RectanglesCanvas.TranslatePoint(new Point(0, 0), RectanglesBorder); + return (viewBoxZoomFactor, -canvasOriginInBorder.X, -canvasOriginInBorder.Y); + } + + private WordBorder CreateWordBorderFromSourceRect( + Windows.Foundation.Rect sourceRect, + double sourceScale, + string text, + int lineNumber, + SolidColorBrush backgroundBrush, + DpiScale dpi, + double viewBoxZoomFactor, + double borderToCanvasX, + double borderToCanvasY) + { + return new() + { + Width = ((sourceRect.Width / (dpi.DpiScaleX * sourceScale)) + 2) / viewBoxZoomFactor, + Height = ((sourceRect.Height / (dpi.DpiScaleY * sourceScale)) + 2) / viewBoxZoomFactor, + Top = ((sourceRect.Y / (dpi.DpiScaleY * sourceScale) - 1) + borderToCanvasY) / viewBoxZoomFactor, + Left = ((sourceRect.X / (dpi.DpiScaleX * sourceScale) - 1) + borderToCanvasX) / viewBoxZoomFactor, + Word = text, + OwnerGrabFrame = this, + LineNumber = lineNumber, + IsFromEditWindow = IsFromEditWindow, + MatchingBackground = backgroundBrush, + }; + } + + private void AddRenderedWordBorder(WordBorder wordBorderBox) + { + if (!IsOcrValid) + return; + + wordBorders.Add(wordBorderBox); + _ = RectanglesCanvas.Children.Add(wordBorderBox); + + UndoRedo.InsertUndoRedoOperation(UndoRedoOperation.AddWordBorder, + new GrabFrameOperationArgs() + { + WordBorder = wordBorderBox, + WordBorders = wordBorders, + GrabFrameCanvas = RectanglesCanvas + }); + } + + private Task DrawRectanglesAroundWords(string searchWord = "") + { + return CurrentLanguage is UiAutomationLang + ? DrawUiAutomationRectanglesAsync(searchWord) + : DrawOcrRectanglesAsync(searchWord); + } + + private async Task DrawOcrRectanglesAsync(string searchWord = "") { if (isDrawing || IsDragOver) return; @@ -1232,8 +1369,7 @@ private async Task DrawRectanglesAroundWords(string searchWord = "") if (string.IsNullOrWhiteSpace(searchWord)) searchWord = SearchBox.Text; - RectanglesCanvas.Children.Clear(); - wordBorders.Clear(); + ClearRenderedWordBorders(); DpiScale dpi = VisualTreeHelper.GetDpi(this); System.Drawing.Rectangle rectCanvasSize = GetContentAreaScreenRect(); @@ -1255,22 +1391,22 @@ private async Task DrawRectanglesAroundWords(string searchWord = "") isSpaceJoining = CurrentLanguage!.IsSpaceJoining(); - System.Drawing.Bitmap? bmp = Singleton.Instance.CachedBitmap; + System.Drawing.Bitmap? bmp = null; bool shouldDisposeBmp = false; - if (bmp is null && frameContentImageSource is BitmapSource bmpImg) + if (isStaticImageSource && frameContentImageSource is BitmapSource bmpImg) { bmp = ImageMethods.BitmapSourceToBitmap(bmpImg); shouldDisposeBmp = true; } + else + { + bmp = ImageMethods.GetRegionOfScreenAsBitmap(rectCanvasSize, cacheResult: false); + shouldDisposeBmp = true; + } int lineNumber = 0; - double viewBoxZoomFactor = CanvasViewBox.GetHorizontalScaleFactor(); - if (!double.IsFinite(viewBoxZoomFactor) || viewBoxZoomFactor <= 0 || viewBoxZoomFactor > 4) - viewBoxZoomFactor = 1; - Point canvasOriginInBorder = RectanglesCanvas.TranslatePoint(new Point(0, 0), RectanglesBorder); - double borderToCanvasX = -canvasOriginInBorder.X; - double borderToCanvasY = -canvasOriginInBorder.Y; + (double viewBoxZoomFactor, double borderToCanvasX, double borderToCanvasY) = GetOverlayRenderMetrics(); foreach (IOcrLine ocrLine in ocrResultOfWindow.Lines) { @@ -1293,18 +1429,16 @@ private async Task DrawRectanglesAroundWords(string searchWord = "") if (DefaultSettings.CorrectToLatin) ocrText = ocrText.ReplaceGreekOrCyrillicWithLatin(); - WordBorder wordBorderBox = new() - { - Width = ((lineRect.Width / (dpi.DpiScaleX * windowFrameImageScale)) + 2) / viewBoxZoomFactor, - Height = ((lineRect.Height / (dpi.DpiScaleY * windowFrameImageScale)) + 2) / viewBoxZoomFactor, - Top = ((lineRect.Y / (dpi.DpiScaleY * windowFrameImageScale) - 1) + borderToCanvasY) / viewBoxZoomFactor, - Left = ((lineRect.X / (dpi.DpiScaleX * windowFrameImageScale) - 1) + borderToCanvasX) / viewBoxZoomFactor, - Word = ocrText, - OwnerGrabFrame = this, - LineNumber = lineNumber, - IsFromEditWindow = IsFromEditWindow, - MatchingBackground = backgroundBrush, - }; + WordBorder wordBorderBox = CreateWordBorderFromSourceRect( + lineRect, + windowFrameImageScale, + ocrText, + lineNumber, + backgroundBrush, + dpi, + viewBoxZoomFactor, + borderToCanvasX, + borderToCanvasY); if (CurrentLanguage!.IsRightToLeft()) { @@ -1314,19 +1448,7 @@ private async Task DrawRectanglesAroundWords(string searchWord = "") wordBorderBox.Word = sb.ToString(); } - if (IsOcrValid) - { - wordBorders.Add(wordBorderBox); - _ = RectanglesCanvas.Children.Add(wordBorderBox); - - UndoRedo.InsertUndoRedoOperation(UndoRedoOperation.AddWordBorder, - new GrabFrameOperationArgs() - { - WordBorder = wordBorderBox, - WordBorders = wordBorders, - GrabFrameCanvas = RectanglesCanvas - }); - } + AddRenderedWordBorder(wordBorderBox); lineNumber++; } @@ -1353,6 +1475,114 @@ private async Task DrawRectanglesAroundWords(string searchWord = "") } } + private async Task DrawUiAutomationRectanglesAsync(string searchWord = "") + { + if (isDrawing || IsDragOver) + return; + + isDrawing = true; + IsOcrValid = true; + + if (string.IsNullOrWhiteSpace(searchWord)) + searchWord = SearchBox.Text; + + ClearRenderedWordBorders(); + + DpiScale dpi = VisualTreeHelper.GetDpi(this); + System.Drawing.Rectangle rectCanvasSize = GetContentAreaScreenRect(); + if (rectCanvasSize.Width <= 0 || rectCanvasSize.Height <= 0) + { + isDrawing = false; + reDrawTimer.Start(); + return; + } + + UiAutomationOverlaySnapshot? overlaySnapshot = null; + if (isStaticImageSource && frozenUiAutomationSnapshot is not null) + { + overlaySnapshot = frozenUiAutomationSnapshot; + } + else + { + liveUiAutomationSnapshot = await UIAutomationUtilities.GetOverlaySnapshotFromRegionAsync( + new Rect(rectCanvasSize.X, rectCanvasSize.Y, rectCanvasSize.Width, rectCanvasSize.Height), + GetUiAutomationExcludedHandles()); + overlaySnapshot = liveUiAutomationSnapshot; + } + + if (overlaySnapshot is null || overlaySnapshot.Items.Count == 0) + { + isDrawing = false; + + if (DefaultSettings.UiAutomationFallbackToOcr) + { + await DrawOcrRectanglesAsync(searchWord); + return; + } + + reSearchTimer.Start(); + return; + } + + System.Drawing.Bitmap? bmp = Singleton.Instance.CachedBitmap; + bool shouldDisposeBmp = false; + + if (bmp is null && frameContentImageSource is BitmapSource bmpImg) + { + bmp = ImageMethods.BitmapSourceToBitmap(bmpImg); + shouldDisposeBmp = true; + } + + (double viewBoxZoomFactor, double borderToCanvasX, double borderToCanvasY) = GetOverlayRenderMetrics(); + Rect sourceBounds = overlaySnapshot.CaptureBounds; + int lineNumber = 0; + + foreach (UiAutomationOverlayItem overlayItem in overlaySnapshot.Items) + { + Rect relativeBounds = new( + overlayItem.ScreenBounds.X - sourceBounds.X, + overlayItem.ScreenBounds.Y - sourceBounds.Y, + overlayItem.ScreenBounds.Width, + overlayItem.ScreenBounds.Height); + + if (relativeBounds == Rect.Empty || relativeBounds.Width < 1 || relativeBounds.Height < 1) + continue; + + Windows.Foundation.Rect sourceRect = new(relativeBounds.X, relativeBounds.Y, relativeBounds.Width, relativeBounds.Height); + SolidColorBrush backgroundBrush = new(Colors.Black); + + if (bmp is not null) + backgroundBrush = GetBackgroundBrushFromBitmap(ref dpi, 1, bmp, ref sourceRect); + + WordBorder wordBorderBox = CreateWordBorderFromSourceRect( + sourceRect, + 1, + overlayItem.Text, + lineNumber, + backgroundBrush, + dpi, + viewBoxZoomFactor, + borderToCanvasX, + borderToCanvasY); + + AddRenderedWordBorder(wordBorderBox); + lineNumber++; + } + + isDrawing = false; + + if (shouldDisposeBmp) + bmp?.Dispose(); + + reSearchTimer.Start(); + + if (isTranslationEnabled && WindowsAiUtilities.CanDeviceUseWinAI()) + { + translationTimer.Stop(); + translationTimer.Start(); + } + } + private void EditMatchesMenuItem_Click(object sender, RoutedEventArgs e) { List selectedWords = [.. wordBorders.Where(m => m.IsSelected)]; @@ -1471,6 +1701,7 @@ private void FreezeGrabFrame() else { isStaticImageSource = false; + frozenUiAutomationSnapshot = null; frameContentImageSource = ImageMethods.GetWindowBoundsImage(this); GrabFrameImage.Source = frameContentImageSource; } @@ -1879,7 +2110,10 @@ private void LanguagesComboBox_MouseDown(object sender, MouseButtonEventArgs e) private void NotifyIfUiAutomationNeedsLiveSource(ILanguage language) { - if (language is not UiAutomationLang || !isStaticImageSource) + if (!CaptureLanguageUtilities.RequiresLiveUiAutomationSource( + language, + isStaticImageSource, + frozenUiAutomationSnapshot is not null)) return; string message = DefaultSettings.UiAutomationFallbackToOcr @@ -1891,11 +2125,20 @@ private void NotifyIfUiAutomationNeedsLiveSource(ILanguage language) private void LanguagesComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) { - if (!isLanguageBoxLoaded - || sender is not ComboBox langComboBox + if (sender is not ComboBox langComboBox || langComboBox.SelectedItem is not ILanguage pickedLang) return; + if (isSyncingLanguageSelection) + { + currentLanguage = pickedLang; + return; + } + + if (!isLanguageBoxLoaded) + return; + + HideFrameMessage(); currentLanguage = pickedLang; CaptureLanguageUtilities.PersistSelectedLanguage(pickedLang); NotifyIfUiAutomationNeedsLiveSource(pickedLang); @@ -1915,15 +2158,24 @@ private async Task LoadOcrLanguagesAsync() foreach (ILanguage language in availableLanguages) LanguagesComboBox.Items.Add(language); + ILanguage preferredLanguage = currentLanguage ?? LanguageUtilities.GetOCRLanguage(); int selectedIndex = CaptureLanguageUtilities.FindPreferredLanguageIndex( availableLanguages, - DefaultSettings.LastUsedLang, - LanguageUtilities.GetOCRLanguage()); + currentLanguage?.LanguageTag ?? DefaultSettings.LastUsedLang, + preferredLanguage); if (selectedIndex >= 0) { - LanguagesComboBox.SelectedIndex = selectedIndex; - currentLanguage = availableLanguages[selectedIndex]; + isSyncingLanguageSelection = true; + try + { + LanguagesComboBox.SelectedIndex = selectedIndex; + currentLanguage = availableLanguages[selectedIndex]; + } + finally + { + isSyncingLanguageSelection = false; + } } isLanguageBoxLoaded = true; @@ -2066,6 +2318,8 @@ private async void PasteExecuted(object sender, ExecutedRoutedEventArgs? e = nul hasLoadedImageSource = true; isStaticImageSource = true; + frozenUiAutomationSnapshot = null; + liveUiAutomationSnapshot = null; FreezeToggleButton.IsChecked = true; FreezeGrabFrame(); FreezeToggleButton.Visibility = Visibility.Collapsed; @@ -2277,6 +2531,16 @@ private async void ReDrawTimer_Tick(object? sender, EventArgs? e) private async void RefreshBTN_Click(object? sender = null, RoutedEventArgs? e = null) { + if (CaptureLanguageUtilities.RequiresLiveUiAutomationSource( + CurrentLanguage, + isStaticImageSource, + frozenUiAutomationSnapshot is not null)) + { + ShowFrameMessage("Cannot use UI Automation on a saved image. Switch to an OCR language to refresh."); + return; + } + + HideFrameMessage(); reDrawTimer.Stop(); UndoRedo.StartTransaction(); @@ -2298,8 +2562,7 @@ private async void RefreshBTN_Click(object? sender = null, RoutedEventArgs? e = RectanglesCanvas.RenderTransform = Transform.Identity; IsOcrValid = false; ocrResultOfWindow = null; - RectanglesCanvas.Children.Clear(); - wordBorders.Clear(); + ClearRenderedWordBorders(); MatchesTXTBLK.Text = "- Matches"; UpdateFrameText(); @@ -2419,12 +2682,12 @@ private void ResetGrabFrame() GrabFrameImage.ClearValue(HeightProperty); IsOcrValid = false; ocrResultOfWindow = null; + liveUiAutomationSnapshot = null; if (!hasLoadedImageSource) frameContentImageSource = null; - RectanglesCanvas.Children.Clear(); - wordBorders.Clear(); + ClearRenderedWordBorders(); MatchesTXTBLK.Text = "- Matches"; UpdateFrameText(); } @@ -2817,6 +3080,8 @@ private async Task TryLoadImageFromPath(string path) frameContentImageSource = droppedImage; hasLoadedImageSource = true; isStaticImageSource = true; + frozenUiAutomationSnapshot = null; + liveUiAutomationSnapshot = null; _currentImagePath = path; FreezeToggleButton.IsChecked = true; FreezeGrabFrame(); @@ -2957,6 +3222,9 @@ private void UnfreezeGrabFrame() { reDrawTimer.Stop(); hasLoadedImageSource = false; + isStaticImageSource = false; + frozenUiAutomationSnapshot = null; + liveUiAutomationSnapshot = null; ResetGrabFrame(); Topmost = true; GrabFrameImage.Opacity = 0; @@ -3211,8 +3479,7 @@ private void InvertColorsMI_Click(object sender, RoutedEventArgs e) }); reDrawTimer.Stop(); - RectanglesCanvas.Children.Clear(); - wordBorders.Clear(); + ClearRenderedWordBorders(); if (!IsFreezeMode) FreezeGrabFrame(); From eff76434027533ffbd6d26a5acd6da206039224d Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 7 Mar 2026 19:03:25 -0600 Subject: [PATCH 065/109] Rename "UI Automation" language option to "Direct Text" Update all references from "UI Automation" to "Direct Text" in both code and UI. This includes changing the abbreviated name to "DT" and updating display, native, and culture names in UiAutomationLang. Adjust UI labels, descriptions, and toggle switches in LanguageSettings.xaml to reflect the new terminology. No functional changes, only terminology updates for clarity. --- Text-Grab/Models/UiAutomationLang.cs | 8 ++++---- Text-Grab/Pages/LanguageSettings.xaml | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Text-Grab/Models/UiAutomationLang.cs b/Text-Grab/Models/UiAutomationLang.cs index fb993a60..713df09c 100644 --- a/Text-Grab/Models/UiAutomationLang.cs +++ b/Text-Grab/Models/UiAutomationLang.cs @@ -7,19 +7,19 @@ public class UiAutomationLang : ILanguage { public const string Tag = "UIAutomation"; - public string AbbreviatedName => "UIA"; + public string AbbreviatedName => "DT"; - public string DisplayName => "UI Automation Text"; + public string DisplayName => "Direct Text"; public string CurrentInputMethodLanguageTag => string.Empty; - public string CultureDisplayName => "UI Automation Text"; + public string CultureDisplayName => "Direct Text"; public string LanguageTag => Tag; public LanguageLayoutDirection LayoutDirection => LanguageLayoutDirection.Ltr; - public string NativeName => "UI Automation Text"; + public string NativeName => "Direct Text"; public string Script => string.Empty; } diff --git a/Text-Grab/Pages/LanguageSettings.xaml b/Text-Grab/Pages/LanguageSettings.xaml index 0d240b19..bd668561 100644 --- a/Text-Grab/Pages/LanguageSettings.xaml +++ b/Text-Grab/Pages/LanguageSettings.xaml @@ -62,19 +62,19 @@ FontSize="18" FontWeight="Bold" Style="{StaticResource TextBodyNormal}" - Text="UI Automation Text" /> + Text="Direct Text" /> - When the UI Automation language is selected, Text Grab will try to read native accessibility text from live application controls before falling back to OCR. + 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 UI Automation as a language option + Show Direct Text as a language option - Include offscreen UI Automation elements + Include offscreen Direct Text elements From a1f9a28f96b2fe2fe7233ce680ccb3075991ec43 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 01:16:29 +0000 Subject: [PATCH 066/109] Initial plan From 06c4f510c25275707fd90b29de18024a69136bde Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 01:20:35 +0000 Subject: [PATCH 067/109] Fix bitmap disposal, isDrawing reset, and indentation per review comments Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com> --- Text-Grab/Utilities/OcrUtilities.cs | 4 ++-- Text-Grab/Views/GrabFrame.xaml.cs | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Text-Grab/Utilities/OcrUtilities.cs b/Text-Grab/Utilities/OcrUtilities.cs index fcaa4c20..cc794ebf 100644 --- a/Text-Grab/Utilities/OcrUtilities.cs +++ b/Text-Grab/Utilities/OcrUtilities.cs @@ -133,7 +133,7 @@ public static async Task GetRegionsTextAsTableAsync(Window passedWindow, int thisCorrectedTop = (int)absPosPoint.Y + selectedRegion.Top; Rectangle correctedRegion = new(thisCorrectedLeft, thisCorrectedTop, selectedRegion.Width, selectedRegion.Height); - Bitmap bmp = ImageMethods.GetRegionOfScreenAsBitmap(correctedRegion); + using Bitmap bmp = ImageMethods.GetRegionOfScreenAsBitmap(correctedRegion); double scale = await GetIdealScaleFactorForOcrAsync(bmp, compatibleLanguage); using Bitmap scaledBitmap = ImageMethods.ScaleBitmapUniform(bmp, scale); DpiScale dpiScale = VisualTreeHelper.GetDpi(passedWindow); @@ -212,7 +212,7 @@ public static async Task GetTextFromBitmapSourceAsTableAsync(BitmapSourc public static async Task<(IOcrLinesWords?, double)> GetOcrResultFromRegionAsync(Rectangle region, ILanguage language) { language = GetCompatibleOcrLanguage(language); - Bitmap bmp = ImageMethods.GetRegionOfScreenAsBitmap(region); + using Bitmap bmp = ImageMethods.GetRegionOfScreenAsBitmap(region); if (language is WindowsAiLang) { diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index 949000f6..44d9a1e3 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -1387,7 +1387,11 @@ private async Task DrawOcrRectanglesAsync(string searchWord = "") } if (ocrResultOfWindow is null) + { + isDrawing = false; + reDrawTimer.Start(); return; + } isSpaceJoining = CurrentLanguage!.IsSpaceJoining(); @@ -2687,7 +2691,7 @@ private void ResetGrabFrame() if (!hasLoadedImageSource) frameContentImageSource = null; - ClearRenderedWordBorders(); + ClearRenderedWordBorders(); MatchesTXTBLK.Text = "- Matches"; UpdateFrameText(); } From 1fcc08a4c9696bb523b6697fd80c6f1af0a987b2 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sat, 7 Mar 2026 19:41:32 -0600 Subject: [PATCH 068/109] Remove using statement for selectionBitmap in table OCR Disposing selectionBitmap with a using statement caused app crashes. Now, the bitmap is not disposed immediately, and a comment was added to highlight the issue and the need for further investigation. --- Text-Grab/Views/FullscreenGrab.SelectionStyles.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs b/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs index c518740e..777e916d 100644 --- a/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs +++ b/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs @@ -1064,7 +1064,9 @@ private async Task CommitSelectionAsync(FullscreenCaptureResult selection, bool } else if (isTable) { - using Bitmap selectionBitmap = ImageMethods.GetRegionOfScreenAsBitmap(selection.CaptureRegion.AsRectangle()); + // TODO: Look into why this happens and find a better way to dispose the bitmap + // DO NOT add a using statement to this selected bitmap, it crashes the app + Bitmap selectionBitmap = ImageMethods.GetRegionOfScreenAsBitmap(selection.CaptureRegion.AsRectangle()); TextFromOCR = await OcrUtilities.GetTextFromBitmapAsTableAsync(selectionBitmap, selectedOcrLang); } else From 4036c0dfd35b28dcbc6cbd916b33e39b14aa5490 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 8 Mar 2026 22:59:52 -0500 Subject: [PATCH 069/109] Improve language picker to use real and keyboard languages Refined LanguagePicker to filter out internal OCR engine languages (UiAutomationLang, WindowsAiLang) and instead use the current keyboard input language for selection when needed. Updated imports and clarified parameter naming in GlobalLang. Changed UiAutomationLang tag and display values for clarity. This ensures the picker only shows real, user-facing languages and improves user experience. --- Text-Grab/Controls/LanguagePicker.xaml.cs | 17 +++++++++++++++++ Text-Grab/Models/GlobalLang.cs | 10 +++++----- Text-Grab/Models/UiAutomationLang.cs | 2 +- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/Text-Grab/Controls/LanguagePicker.xaml.cs b/Text-Grab/Controls/LanguagePicker.xaml.cs index 0ee7c2e7..1edc9075 100644 --- a/Text-Grab/Controls/LanguagePicker.xaml.cs +++ b/Text-Grab/Controls/LanguagePicker.xaml.cs @@ -1,7 +1,10 @@ using System.Collections.ObjectModel; +using System.Globalization; using System.Windows; using System.Windows.Controls; +using System.Windows.Input; using Text_Grab.Interfaces; +using Text_Grab.Models; using Text_Grab.Utilities; namespace Text_Grab.Controls; @@ -33,10 +36,24 @@ private void UserControl_Loaded(object sender, RoutedEventArgs e) ILanguage currentSelectedLanguage = LanguageUtilities.GetOCRLanguage(); + // get current keyboard language + CultureInfo keyboardLanguage = InputLanguageManager.Current.CurrentInputLanguage; + + // The challenge here is that UI Automation and Windows AI support any langauage + // since this picker will set the spell checker language and stuff like that + // it needs to represent real languages and not just OCR engine target languages + // As new models are supported they will need to be caught and filtered here too + + if (currentSelectedLanguage is UiAutomationLang or WindowsAiLang) + currentSelectedLanguage = new GlobalLang(keyboardLanguage.Name); + int selectedIndex = 0; int i = 0; foreach (ILanguage langFromUtil in LanguageUtilities.GetAllLanguages()) { + if (langFromUtil is UiAutomationLang or WindowsAiLang) + continue; + Languages.Add(langFromUtil); if (langFromUtil.LanguageTag == currentSelectedLanguage.LanguageTag) selectedIndex = i; diff --git a/Text-Grab/Models/GlobalLang.cs b/Text-Grab/Models/GlobalLang.cs index 09193fcf..7f4a1288 100644 --- a/Text-Grab/Models/GlobalLang.cs +++ b/Text-Grab/Models/GlobalLang.cs @@ -15,19 +15,19 @@ public GlobalLang(Windows.Globalization.Language lang) OriginalLanguage = lang; } - public GlobalLang(string inputLang) + public GlobalLang(string inputLangTag) { - if (inputLang == "English") - inputLang = "en-US"; + if (inputLangTag == "English") + inputLangTag = "en-US"; Windows.Globalization.Language language = new(System.Globalization.CultureInfo.CurrentCulture.Name); try { - language = new(inputLang); + language = new(inputLangTag); } catch (System.ArgumentException ex) { - System.Diagnostics.Debug.WriteLine($"Failed to initialize language '{inputLang}': {ex.Message}"); + System.Diagnostics.Debug.WriteLine($"Failed to initialize language '{inputLangTag}': {ex.Message}"); } AbbreviatedName = language.AbbreviatedName; CultureDisplayName = language.DisplayName; diff --git a/Text-Grab/Models/UiAutomationLang.cs b/Text-Grab/Models/UiAutomationLang.cs index 713df09c..e7cc18d3 100644 --- a/Text-Grab/Models/UiAutomationLang.cs +++ b/Text-Grab/Models/UiAutomationLang.cs @@ -5,7 +5,7 @@ namespace Text_Grab.Models; public class UiAutomationLang : ILanguage { - public const string Tag = "UIAutomation"; + public const string Tag = "Direct-Txt"; public string AbbreviatedName => "DT"; From c22c59539fa65ae43477ee299df0df1cb6e2c84b Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Mon, 9 Mar 2026 21:27:41 -0500 Subject: [PATCH 070/109] 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 071/109] 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 072/109] 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 073/109] 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 074/109] 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 075/109] 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 076/109] 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 077/109] 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 078/109] 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 079/109] 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 080/109] 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 081/109] 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 082/109] 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 083/109] 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 084/109] 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 085/109] 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" + } + }); } From 5879d95d1238ecd8b9179c7c6b214487ba7bd34e Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 18 Mar 2026 20:17:27 -0500 Subject: [PATCH 086/109] add regex mgmt to etw menu --- Text-Grab/Views/EditTextWindow.xaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Text-Grab/Views/EditTextWindow.xaml b/Text-Grab/Views/EditTextWindow.xaml index 3b25d90d..27648940 100644 --- a/Text-Grab/Views/EditTextWindow.xaml +++ b/Text-Grab/Views/EditTextWindow.xaml @@ -461,6 +461,10 @@ x:Name="ManageGrabTemplatesMenuItem" Click="ManageGrabTemplates_Click" Header="Manage Grab _Templates..." /> + Date: Wed, 18 Mar 2026 20:19:37 -0500 Subject: [PATCH 087/109] fix: guard against COM exception in TryCreateTextRangeOverlayItem Catch InvalidOperationException, ElementNotAvailableException, and COMException from range.GetText(-1) to prevent unhandled exception crash when a UIA text range becomes invalid mid-enumeration. Co-Authored-By: Claude Sonnet 4.6 --- Text-Grab/Utilities/UIAutomationUtilities.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Text-Grab/Utilities/UIAutomationUtilities.cs b/Text-Grab/Utilities/UIAutomationUtilities.cs index 571e5098..0987874c 100644 --- a/Text-Grab/Utilities/UIAutomationUtilities.cs +++ b/Text-Grab/Utilities/UIAutomationUtilities.cs @@ -1018,7 +1018,18 @@ private static bool TryCreateTextRangeOverlayItem( out UiAutomationOverlayItem overlayItem) { overlayItem = default!; - string text = NormalizeText(range.GetText(-1)); + + string rawText; + try + { + rawText = range.GetText(-1); + } + catch (Exception ex) when (ex is InvalidOperationException or ElementNotAvailableException or System.Runtime.InteropServices.COMException) + { + return false; + } + + string text = NormalizeText(rawText); if (string.IsNullOrWhiteSpace(text)) return false; From 9253c36173d517c672e928f888afaf5b6fd2feea Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 18 Mar 2026 22:25:25 -0500 Subject: [PATCH 088/109] refactor: replace System.Windows.MessageBox with Wpf.Ui.Controls.MessageBox Co-Authored-By: Claude Sonnet 4.6 --- Text-Grab/Controls/WordBorder.xaml.cs | 16 +++-- Text-Grab/Pages/GeneralSettings.xaml.cs | 26 ++++---- Text-Grab/Pages/LanguageSettings.xaml.cs | 7 +- Text-Grab/Utilities/IoUtilities.cs | 7 +- Text-Grab/Utilities/WindowUtilities.cs | 7 +- .../Views/FullscreenGrab.SelectionStyles.cs | 11 ++-- Text-Grab/Views/GrabFrame.xaml.cs | 64 +++++++++++++------ 7 files changed, 95 insertions(+), 43 deletions(-) diff --git a/Text-Grab/Controls/WordBorder.xaml.cs b/Text-Grab/Controls/WordBorder.xaml.cs index 41f94eb6..44a80425 100644 --- a/Text-Grab/Controls/WordBorder.xaml.cs +++ b/Text-Grab/Controls/WordBorder.xaml.cs @@ -482,8 +482,12 @@ private async void TranslateWordMenuItem_Click(object sender, RoutedEventArgs e) if (!WindowsAiUtilities.CanDeviceUseWinAI()) { - MessageBox.Show("Windows AI is not available on this device.", - "Translation Not Available", MessageBoxButton.OK, MessageBoxImage.Information); + await new Wpf.Ui.Controls.MessageBox + { + Title = "Translation Not Available", + Content = "Windows AI is not available on this device.", + CloseButtonText = "OK" + }.ShowDialogAsync(); return; } @@ -513,8 +517,12 @@ private async void TranslateWordMenuItem_Click(object sender, RoutedEventArgs e) catch (Exception ex) { Debug.WriteLine($"Translation failed: {ex.Message}"); - MessageBox.Show($"Translation failed: {ex.Message}", - "Translation Error", MessageBoxButton.OK, MessageBoxImage.Error); + await new Wpf.Ui.Controls.MessageBox + { + Title = "Translation Error", + Content = $"Translation failed: {ex.Message}", + CloseButtonText = "OK" + }.ShowDialogAsync(); } } diff --git a/Text-Grab/Pages/GeneralSettings.xaml.cs b/Text-Grab/Pages/GeneralSettings.xaml.cs index 0050e3f5..987e789f 100644 --- a/Text-Grab/Pages/GeneralSettings.xaml.cs +++ b/Text-Grab/Pages/GeneralSettings.xaml.cs @@ -393,7 +393,7 @@ private void WebSearchersComboBox_SelectionChanged(object sender, SelectionChang Singleton.Instance.DefaultSearcher = newDefault; } - private void AddToContextMenuCheckBox_Checked(object sender, RoutedEventArgs e) + private async void AddToContextMenuCheckBox_Checked(object sender, RoutedEventArgs e) { if (!settingsSet) return; @@ -412,15 +412,16 @@ private void AddToContextMenuCheckBox_Checked(object sender, RoutedEventArgs e) settingsSet = true; // Show error message to user - System.Windows.MessageBox.Show( - errorMessage ?? "Failed to add Text Grab to the context menu.", - "Context Menu Registration Failed", - System.Windows.MessageBoxButton.OK, - MessageBoxImage.Warning); + await new Wpf.Ui.Controls.MessageBox + { + Title = "Context Menu Registration Failed", + Content = errorMessage ?? "Failed to add Text Grab to the context menu.", + CloseButtonText = "OK" + }.ShowDialogAsync(); } } - private void AddToContextMenuCheckBox_Unchecked(object sender, RoutedEventArgs e) + private async void AddToContextMenuCheckBox_Unchecked(object sender, RoutedEventArgs e) { if (!settingsSet) return; @@ -439,11 +440,12 @@ private void AddToContextMenuCheckBox_Unchecked(object sender, RoutedEventArgs e AddToContextMenuCheckBox.IsChecked = true; settingsSet = true; - System.Windows.MessageBox.Show( - errorMessage ?? "Some context menu entries could not be removed.", - "Context Menu Removal Failed", - System.Windows.MessageBoxButton.OK, - MessageBoxImage.Warning); + await new Wpf.Ui.Controls.MessageBox + { + Title = "Context Menu Removal Failed", + Content = errorMessage ?? "Some context menu entries could not be removed.", + CloseButtonText = "OK" + }.ShowDialogAsync(); } } diff --git a/Text-Grab/Pages/LanguageSettings.xaml.cs b/Text-Grab/Pages/LanguageSettings.xaml.cs index 83f3628a..700ca866 100644 --- a/Text-Grab/Pages/LanguageSettings.xaml.cs +++ b/Text-Grab/Pages/LanguageSettings.xaml.cs @@ -255,7 +255,12 @@ public async Task CopyFileWithElevatedPermissions(string sourcePath, string dest { // The user refused the elevation. // Handle this situation as you prefer. - MessageBox.Show(ex.Message); + await new Wpf.Ui.Controls.MessageBox + { + Title = "Error", + Content = ex.Message, + CloseButtonText = "OK" + }.ShowDialogAsync(); } } diff --git a/Text-Grab/Utilities/IoUtilities.cs b/Text-Grab/Utilities/IoUtilities.cs index 5d0a11ec..748eb472 100644 --- a/Text-Grab/Utilities/IoUtilities.cs +++ b/Text-Grab/Utilities/IoUtilities.cs @@ -43,7 +43,12 @@ public static bool IsImageFileExtension(string extension) } catch (Exception) { - System.Windows.MessageBox.Show($"Failed to read {pathOfFileToOpen}"); + await new Wpf.Ui.Controls.MessageBox + { + Title = "Error", + Content = $"Failed to read {pathOfFileToOpen}", + CloseButtonText = "OK" + }.ShowDialogAsync(); } } else diff --git a/Text-Grab/Utilities/WindowUtilities.cs b/Text-Grab/Utilities/WindowUtilities.cs index 3c61f623..bcf95aed 100644 --- a/Text-Grab/Utilities/WindowUtilities.cs +++ b/Text-Grab/Utilities/WindowUtilities.cs @@ -317,7 +317,12 @@ private static void TryInjectModifierKeyUp(ref List inputs, VirtualKeySho } catch (Exception ex) { - MessageBox.Show("An error occurred while trying to open a new window. Please try again.", ex.Message); + _ = new Wpf.Ui.Controls.MessageBox + { + Title = ex.Message, + Content = "An error occurred while trying to open a new window. Please try again.", + CloseButtonText = "OK" + }.ShowDialogAsync(); } return newWindow; } diff --git a/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs b/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs index 840c1d39..a40dc87c 100644 --- a/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs +++ b/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs @@ -1135,11 +1135,12 @@ private async Task CommitSelectionAsync(FullscreenCaptureResult selection, bool { if (!showedFreeformTemplateMessage) { - MessageBox.Show( - "Grab Templates are currently available only for rectangular selections. Freeform captures will keep their OCR text without applying templates.", - "Text Grab", - MessageBoxButton.OK, - MessageBoxImage.Information); + await new Wpf.Ui.Controls.MessageBox + { + Title = "Text Grab", + Content = "Grab Templates are currently available only for rectangular selections. Freeform captures will keep their OCR text without applying templates.", + CloseButtonText = "OK" + }.ShowDialogAsync(); showedFreeformTemplateMessage = true; } diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index 7e2c03fb..60d074d4 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -126,7 +126,12 @@ public GrabFrame(string imagePath) if (string.IsNullOrEmpty(imagePath)) { Debug.WriteLine("GrabFrame: Empty image path provided"); - Loaded += (s, e) => MessageBox.Show("No image file path was provided.", "Text Grab", MessageBoxButton.OK, MessageBoxImage.Warning); + Loaded += async (s, e) => await new Wpf.Ui.Controls.MessageBox + { + Title = "Text Grab", + Content = "No image file path was provided.", + CloseButtonText = "OK" + }.ShowDialogAsync(); return; } @@ -136,7 +141,12 @@ public GrabFrame(string imagePath) if (!File.Exists(absolutePath)) { Debug.WriteLine($"GrabFrame: Image file not found: {absolutePath}"); - Loaded += (s, e) => MessageBox.Show($"Image file not found:\n{absolutePath}", "Text Grab", MessageBoxButton.OK, MessageBoxImage.Warning); + Loaded += async (s, e) => await new Wpf.Ui.Controls.MessageBox + { + Title = "Text Grab", + Content = $"Image file not found:\n{absolutePath}", + CloseButtonText = "OK" + }.ShowDialogAsync(); return; } @@ -2120,7 +2130,7 @@ private void LanguagesComboBox_MouseDown(object sender, MouseButtonEventArgs e) } } - private void NotifyIfUiAutomationNeedsLiveSource(ILanguage language) + private async void NotifyIfUiAutomationNeedsLiveSource(ILanguage language) { if (!CaptureLanguageUtilities.RequiresLiveUiAutomationSource( language, @@ -2132,7 +2142,12 @@ private void NotifyIfUiAutomationNeedsLiveSource(ILanguage language) ? "UI Automation reads live application controls. This Grab Frame currently contains a static image, so Text Grab will fall back to OCR for image-only operations." : "UI Automation reads live application controls. This Grab Frame currently contains a static image, so image-only operations will not return UI Automation text."; - MessageBox.Show(message, "Text Grab", MessageBoxButton.OK, MessageBoxImage.Information); + await new Wpf.Ui.Controls.MessageBox + { + Title = "Text Grab", + Content = message, + CloseButtonText = "OK" + }.ShowDialogAsync(); } private void LanguagesComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) @@ -2814,7 +2829,7 @@ private void SaveAsTemplate_Click(object sender, RoutedEventArgs e) UpdateTemplateBadges(); } - private void SaveTemplateSave_Click(object sender, RoutedEventArgs e) + private async void SaveTemplateSave_Click(object sender, RoutedEventArgs e) { string name = TemplateNameBox.Text.Trim(); if (string.IsNullOrWhiteSpace(name)) @@ -2830,11 +2845,12 @@ private void SaveTemplateSave_Click(object sender, RoutedEventArgs e) if (wordBorders.Count == 0 && patternMatches.Count == 0) { - MessageBox.Show( - "Use Ctrl+drag to draw at least one region, or add a pattern placeholder, before saving.", - "No Regions or Patterns", - MessageBoxButton.OK, - MessageBoxImage.Information); + await new Wpf.Ui.Controls.MessageBox + { + Title = "No Regions or Patterns", + Content = "Use Ctrl+drag to draw at least one region, or add a pattern placeholder, before saving.", + CloseButtonText = "OK" + }.ShowDialogAsync(); return; } @@ -2889,11 +2905,12 @@ private void SaveTemplateSave_Click(object sender, RoutedEventArgs e) ? $"{regions.Count} region(s)" : $"{patternMatches.Count} pattern(s)"; - MessageBox.Show( - $"Template \"{name}\" saved with {itemsDesc}.\n\nEnable it in Post-Grab Actions Settings to use it during a Fullscreen Grab.", - "Template Saved", - MessageBoxButton.OK, - MessageBoxImage.Information); + await new Wpf.Ui.Controls.MessageBox + { + Title = "Template Saved", + Content = $"Template \"{name}\" saved with {itemsDesc}.\n\nEnable it in Post-Grab Actions Settings to use it during a Fullscreen Grab.", + CloseButtonText = "OK" + }.ShowDialogAsync(); } /// @@ -3078,7 +3095,12 @@ private async Task TryLoadImageFromPath(string path) { hasLoadedImageSource = false; UnfreezeGrabFrame(); - MessageBox.Show("Not an image"); + await new Wpf.Ui.Controls.MessageBox + { + Title = "Text Grab", + Content = "Not an image", + CloseButtonText = "OK" + }.ShowDialogAsync(); } } @@ -3682,7 +3704,7 @@ private void ReadBarcodesMenuItem_Checked(object sender, RoutedEventArgs e) DefaultSettings.Save(); } - private void TranslateToggleButton_Click(object sender, RoutedEventArgs e) + private async void TranslateToggleButton_Click(object sender, RoutedEventArgs e) { if (TranslateToggleButton.IsChecked is bool isChecked) { @@ -3695,8 +3717,12 @@ private void TranslateToggleButton_Click(object sender, RoutedEventArgs e) { if (!WindowsAiUtilities.CanDeviceUseWinAI()) { - MessageBox.Show("Windows AI is not available on this device. Translation requires Windows AI support.", - "Translation Not Available", MessageBoxButton.OK, MessageBoxImage.Information); + await new Wpf.Ui.Controls.MessageBox + { + Title = "Translation Not Available", + Content = "Windows AI is not available on this device. Translation requires Windows AI support.", + CloseButtonText = "OK" + }.ShowDialogAsync(); TranslateToggleButton.IsChecked = false; isTranslationEnabled = false; return; From f521c5af71392e336ee14d963a25a645f9645c06 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 18 Mar 2026 22:32:06 -0500 Subject: [PATCH 089/109] feat: add text-only grab template support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Templates no longer require regions to be valid — a name and output template are sufficient. Adds a TextOnlyTemplateDialog for creating and editing pattern-only templates, a "New Text-Only" button in PostGrabActionEditor, and ParsePatternMatchesFromOutputTemplate + ExecuteTemplateOnBitmapAsync helpers in GrabTemplateExecutor. Co-Authored-By: Claude Sonnet 4.6 --- Tests/GrabTemplateManagerTests.cs | 16 +- Text-Grab/Controls/PostGrabActionEditor.xaml | 57 +++++-- .../Controls/PostGrabActionEditor.xaml.cs | 78 +++++++--- .../Controls/TextOnlyTemplateDialog.xaml | 82 ++++++++++ .../Controls/TextOnlyTemplateDialog.xaml.cs | 115 ++++++++++++++ Text-Grab/Models/GrabTemplate.cs | 4 +- Text-Grab/Utilities/GrabTemplateExecutor.cs | 144 +++++++++++++++++- 7 files changed, 455 insertions(+), 41 deletions(-) create mode 100644 Text-Grab/Controls/TextOnlyTemplateDialog.xaml create mode 100644 Text-Grab/Controls/TextOnlyTemplateDialog.xaml.cs diff --git a/Tests/GrabTemplateManagerTests.cs b/Tests/GrabTemplateManagerTests.cs index 59610c3a..90ea2ef3 100644 --- a/Tests/GrabTemplateManagerTests.cs +++ b/Tests/GrabTemplateManagerTests.cs @@ -218,12 +218,20 @@ public void GetAllTemplates_CorruptJson_ReturnsEmptyList() } [Fact] - public void GrabTemplate_IsValid_TrueWhenNameRegionsAndOutputTemplateSet() + public void GrabTemplate_IsValid_TrueWhenNameAndOutputTemplateSet() { GrabTemplate template = CreateSampleTemplate("Valid"); Assert.True(template.IsValid); } + [Fact] + public void GrabTemplate_IsValid_TrueWhenNoRegionsButHasNameAndOutputTemplate() + { + GrabTemplate template = CreateSampleTemplate("Text Only"); + template.Regions.Clear(); + Assert.True(template.IsValid); + } + [Fact] public void GrabTemplate_IsValid_FalseWhenNameEmpty() { @@ -232,10 +240,10 @@ public void GrabTemplate_IsValid_FalseWhenNameEmpty() } [Fact] - public void GrabTemplate_IsValid_FalseWhenNoRegions() + public void GrabTemplate_IsValid_FalseWhenOutputTemplateEmpty() { - GrabTemplate template = CreateSampleTemplate("No Regions"); - template.Regions.Clear(); + GrabTemplate template = CreateSampleTemplate("No Output"); + template.OutputTemplate = string.Empty; Assert.False(template.IsValid); } diff --git a/Text-Grab/Controls/PostGrabActionEditor.xaml b/Text-Grab/Controls/PostGrabActionEditor.xaml index b2c9b960..eb639b8f 100644 --- a/Text-Grab/Controls/PostGrabActionEditor.xaml +++ b/Text-Grab/Controls/PostGrabActionEditor.xaml @@ -261,15 +261,42 @@ - - + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Text-Grab/Controls/TextOnlyTemplateDialog.xaml.cs b/Text-Grab/Controls/TextOnlyTemplateDialog.xaml.cs new file mode 100644 index 00000000..b8d3f3c8 --- /dev/null +++ b/Text-Grab/Controls/TextOnlyTemplateDialog.xaml.cs @@ -0,0 +1,115 @@ +using System; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using Text_Grab.Models; +using Text_Grab.Utilities; +using Wpf.Ui.Controls; + +namespace Text_Grab.Controls; + +public partial class TextOnlyTemplateDialog : FluentWindow +{ + /// When set, Save updates this template instead of creating a new one. + public GrabTemplate? EditingTemplate { get; set; } + + public TextOnlyTemplateDialog() + { + InitializeComponent(); + Loaded += OnLoaded; + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + if (EditingTemplate is not null) + { + Title = "Edit Text-Only Template"; + TitleBarControl.Title = "Edit Text-Only Template"; + } + + TemplateNameBox.Focus(); + LoadPatternItems(); + OutputTemplateBox.PatternItemSelected = OnPatternItemSelected; + } + + private void LoadPatternItems() + { + StoredRegex[] patterns = AppUtilities.TextGrabSettingsService.LoadStoredRegexes(); + if (patterns.Length == 0) + patterns = StoredRegex.GetDefaultPatterns(); + + OutputTemplateBox.ItemsSource = [.. patterns.Select(p => + new InlinePickerItem(p.Name, $"{{p:{p.Name}:first}}", "Patterns"))]; + } + + private TemplatePatternMatch? OnPatternItemSelected(InlinePickerItem item) + { + StoredRegex[] patterns = AppUtilities.TextGrabSettingsService.LoadStoredRegexes(); + if (patterns.Length == 0) + patterns = StoredRegex.GetDefaultPatterns(); + + StoredRegex? storedRegex = patterns.FirstOrDefault( + p => p.Name.Equals(item.DisplayName, StringComparison.OrdinalIgnoreCase)); + + PatternMatchModeDialog dialog = new(storedRegex?.Id ?? string.Empty, item.DisplayName) + { + Owner = this, + }; + + return dialog.ShowDialog() is true ? dialog.Result : null; + } + + private void ValidateInput(object sender, TextChangedEventArgs e) => UpdateSaveButton(); + + private void OutputTemplateBox_TextChanged(object sender, TextChangedEventArgs e) => UpdateSaveButton(); + + private void UpdateSaveButton() + { + if (SaveButton is null) + return; + + bool nameOk = !string.IsNullOrWhiteSpace(TemplateNameBox.Text); + bool templateOk = !string.IsNullOrWhiteSpace(OutputTemplateBox.GetSerializedText()); + SaveButton.IsEnabled = nameOk && templateOk; + + if (ErrorText is not null) + ErrorText.Visibility = Visibility.Collapsed; + } + + private void SaveButton_Click(object sender, RoutedEventArgs e) + { + string name = TemplateNameBox.Text.Trim(); + string outputTemplate = OutputTemplateBox.GetSerializedText(); + + if (string.IsNullOrWhiteSpace(name)) + { + ErrorText.Text = "Template name is required."; + ErrorText.Visibility = Visibility.Visible; + TemplateNameBox.Focus(); + return; + } + + if (string.IsNullOrWhiteSpace(outputTemplate)) + { + ErrorText.Text = "Output template is required."; + ErrorText.Visibility = Visibility.Visible; + OutputTemplateBox.Focus(); + return; + } + + GrabTemplate newTemplate = EditingTemplate ?? new(); + newTemplate.Name = name; + newTemplate.OutputTemplate = outputTemplate; + newTemplate.PatternMatches = GrabTemplateExecutor.ParsePatternMatchesFromOutputTemplate(outputTemplate); + + GrabTemplateManager.AddOrUpdateTemplate(newTemplate); + DialogResult = true; + Close(); + } + + private void CancelButton_Click(object sender, RoutedEventArgs e) + { + DialogResult = false; + Close(); + } +} diff --git a/Text-Grab/Models/GrabTemplate.cs b/Text-Grab/Models/GrabTemplate.cs index 986de995..ea063af1 100644 --- a/Text-Grab/Models/GrabTemplate.cs +++ b/Text-Grab/Models/GrabTemplate.cs @@ -87,12 +87,10 @@ public GrabTemplate(string name) /// /// Returns whether this template has the minimum required data to be executed. - /// A template is valid if it has a name, an output template, and at least one - /// region or pattern reference. + /// A template is valid if it has a name and an output template. /// public bool IsValid => !string.IsNullOrWhiteSpace(Name) - && (Regions.Count > 0 || PatternMatches.Count > 0) && !string.IsNullOrWhiteSpace(OutputTemplate); /// diff --git a/Text-Grab/Utilities/GrabTemplateExecutor.cs b/Text-Grab/Utilities/GrabTemplateExecutor.cs index c4f58077..cea400d4 100644 --- a/Text-Grab/Utilities/GrabTemplateExecutor.cs +++ b/Text-Grab/Utilities/GrabTemplateExecutor.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Drawing; using System.Linq; using System.Text.Json; using System.Text.RegularExpressions; @@ -77,8 +78,15 @@ public static async Task ExecuteTemplateAsync( : []; // 2. OCR full capture area for pattern matching (if any pattern references exist) + // Also check if output template has {p:...} placeholders in case PatternMatches wasn't populated on save + bool hasPatternRefs = template.PatternMatches.Count > 0 + || PatternPlaceholderRegex.IsMatch(template.OutputTemplate); + List effectivePatternMatches = template.PatternMatches.Count > 0 + ? template.PatternMatches + : (hasPatternRefs ? ParsePatternMatchesFromOutputTemplate(template.OutputTemplate) : []); + string? fullAreaText = null; - if (template.PatternMatches.Count > 0) + if (hasPatternRefs) { try { @@ -92,14 +100,105 @@ public static async Task ExecuteTemplateAsync( // 3. Resolve pattern regexes from saved patterns Dictionary patternRegexes = []; - if (template.PatternMatches.Count > 0) - patternRegexes = ResolvePatternRegexes(template.PatternMatches); + if (effectivePatternMatches.Count > 0) + patternRegexes = ResolvePatternRegexes(effectivePatternMatches); // 4. Apply output template string output = ApplyOutputTemplate(template.OutputTemplate, regionResults); if (fullAreaText != null) - output = ApplyPatternPlaceholders(output, fullAreaText, template.PatternMatches, patternRegexes); + output = ApplyPatternPlaceholders(output, fullAreaText, effectivePatternMatches, patternRegexes); + + return output; + } + + /// + /// Executes the given template against a file-loaded . + /// Each template region is mapped to a cropped sub-bitmap, OCR'd, then + /// assembled via the output template. + /// + public static async Task ExecuteTemplateOnBitmapAsync( + GrabTemplate template, + Bitmap bitmap, + ILanguage? language = null) + { + if (!template.IsValid) + return string.Empty; + + ILanguage resolvedLanguage = language ?? LanguageUtilities.GetOCRLanguage(); + + // 1. OCR each region (if any) + Dictionary regionResults = []; + if (template.Regions.Count > 0) + { + foreach (TemplateRegion region in template.Regions) + { + int x = (int)(region.RatioLeft * bitmap.Width); + int y = (int)(region.RatioTop * bitmap.Height); + int width = (int)(region.RatioWidth * bitmap.Width); + int height = (int)(region.RatioHeight * bitmap.Height); + + if (width <= 0 || height <= 0) + { + regionResults[region.RegionNumber] = region.DefaultValue; + continue; + } + + // Clamp to bitmap bounds + x = Math.Max(0, Math.Min(x, bitmap.Width - 1)); + y = Math.Max(0, Math.Min(y, bitmap.Height - 1)); + width = Math.Min(width, bitmap.Width - x); + height = Math.Min(height, bitmap.Height - y); + + try + { + using Bitmap regionBitmap = bitmap.Clone( + new Rectangle(x, y, width, height), bitmap.PixelFormat); + string regionText = OcrUtilities.GetStringFromOcrOutputs( + await OcrUtilities.GetTextFromImageAsync(regionBitmap, resolvedLanguage)); + regionResults[region.RegionNumber] = string.IsNullOrWhiteSpace(regionText) + ? region.DefaultValue + : regionText.Trim(); + } + catch (Exception) + { + regionResults[region.RegionNumber] = region.DefaultValue; + } + } + } + + // 2. OCR full bitmap for pattern matching (if any) + // Also check if output template has {p:...} placeholders in case PatternMatches wasn't populated on save + bool hasPatternRefs = template.PatternMatches.Count > 0 + || PatternPlaceholderRegex.IsMatch(template.OutputTemplate); + List effectivePatternMatches = template.PatternMatches.Count > 0 + ? template.PatternMatches + : (hasPatternRefs ? ParsePatternMatchesFromOutputTemplate(template.OutputTemplate) : []); + + string? fullAreaText = null; + if (hasPatternRefs) + { + try + { + fullAreaText = OcrUtilities.GetStringFromOcrOutputs( + await OcrUtilities.GetTextFromImageAsync(bitmap, resolvedLanguage)); + } + catch (Exception) + { + fullAreaText = string.Empty; + } + } + + // 3. Resolve pattern regexes + Dictionary patternRegexes = []; + if (effectivePatternMatches.Count > 0) + patternRegexes = ResolvePatternRegexes(effectivePatternMatches); + + // 4. Apply output template + string output = ApplyOutputTemplate(template.OutputTemplate, regionResults); + + if (fullAreaText != null) + output = ApplyPatternPlaceholders(output, fullAreaText, effectivePatternMatches, patternRegexes); return output; } @@ -280,6 +379,43 @@ internal static Dictionary ResolvePatternRegexes( return result; } + /// + /// Parses {p:Name:mode} and {p:Name:mode:separator} placeholders from + /// and builds objects + /// by resolving against saved patterns. Useful when a template was saved without populating + /// (e.g. via the text-only template dialog). + /// + public static List ParsePatternMatchesFromOutputTemplate(string outputTemplate) + { + if (string.IsNullOrEmpty(outputTemplate)) + return []; + + MatchCollection matches = PatternPlaceholderRegex.Matches(outputTemplate); + Dictionary uniquePatterns = new(StringComparer.OrdinalIgnoreCase); + StoredRegex[] savedPatterns = LoadSavedPatterns(); + + foreach (Match match in matches) + { + string patternName = match.Groups[1].Value; + string mode = match.Groups[2].Value; + string separator = match.Groups[3].Success ? match.Groups[3].Value : ", "; + + if (uniquePatterns.ContainsKey(patternName)) + continue; + + StoredRegex? stored = savedPatterns.FirstOrDefault( + p => p.Name.Equals(patternName, StringComparison.OrdinalIgnoreCase)); + + uniquePatterns[patternName] = new TemplatePatternMatch( + patternId: stored?.Id ?? string.Empty, + patternName: patternName, + matchMode: mode, + separator: separator); + } + + return [.. uniquePatterns.Values]; + } + private static StoredRegex[] LoadSavedPatterns() { StoredRegex[] patterns = AppUtilities.TextGrabSettingsService.LoadStoredRegexes(); From 6d2866cfda24bec197adec526634f36cc6dca7ce Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 18 Mar 2026 22:34:00 -0500 Subject: [PATCH 090/109] feat: apply grab templates to folder image OCR Adds a "Use Grab Template" submenu to the EditTextWindow capture menu so users can pick a template when scanning a folder of images. The selected template is passed through OcrDirectoryOptions and executed per-file via GrabTemplateExecutor.ExecuteTemplateOnBitmapAsync. Also refactors OcrAbsoluteFilePathAsync to expose a reusable LoadBitmapFromFile helper and filters the language list to static-image-compatible languages for folder OCR. Co-Authored-By: Claude Sonnet 4.6 --- Text-Grab/Models/OcrDirectoryOptions.cs | 1 + Text-Grab/Utilities/OcrUtilities.cs | 38 ++++--- Text-Grab/Views/EditTextWindow.xaml | 3 + Text-Grab/Views/EditTextWindow.xaml.cs | 126 +++++++++++++++++++++--- 4 files changed, 140 insertions(+), 28 deletions(-) diff --git a/Text-Grab/Models/OcrDirectoryOptions.cs b/Text-Grab/Models/OcrDirectoryOptions.cs index 99f77d31..fefc64e4 100644 --- a/Text-Grab/Models/OcrDirectoryOptions.cs +++ b/Text-Grab/Models/OcrDirectoryOptions.cs @@ -8,4 +8,5 @@ public record OcrDirectoryOptions public bool OutputFileNames { get; set; } = true; public bool OutputFooter { get; set; } = true; public bool OutputHeader { get; set; } = true; + public GrabTemplate? GrabTemplate { get; set; } = null; } \ No newline at end of file diff --git a/Text-Grab/Utilities/OcrUtilities.cs b/Text-Grab/Utilities/OcrUtilities.cs index b1fe3e69..a908826c 100644 --- a/Text-Grab/Utilities/OcrUtilities.cs +++ b/Text-Grab/Utilities/OcrUtilities.cs @@ -273,7 +273,7 @@ public static async void GetCopyTextFromPreviousRegion() if (lastFsg is null) return; - if (!CanReplayPreviousFullscreenSelection(lastFsg)) + if (!await CanReplayPreviousFullscreenSelection(lastFsg)) return; Rect scaledRect = lastFsg.PositionRect.GetScaledUpByFraction(lastFsg.DpiScaleFactor); @@ -312,7 +312,7 @@ public static async Task GetTextFromPreviousFullscreenRegion(TextBox? destinatio if (lastFsg is null) return; - if (!CanReplayPreviousFullscreenSelection(lastFsg)) + if (!await CanReplayPreviousFullscreenSelection(lastFsg)) return; Rect scaledRect = lastFsg.PositionRect.GetScaledUpByFraction(lastFsg.DpiScaleFactor); @@ -453,9 +453,15 @@ public static string GetStringFromOcrOutputs(List outputs) } public static async Task OcrAbsoluteFilePathAsync(string absolutePath, ILanguage? language = null) + { + Bitmap bmp = LoadBitmapFromFile(absolutePath); + language ??= LanguageUtilities.GetCurrentInputLanguage(); + return GetStringFromOcrOutputs(await GetTextFromImageAsync(bmp, language)); + } + + private static Bitmap LoadBitmapFromFile(string absolutePath) { Uri fileURI = new(absolutePath, UriKind.Absolute); - FileInfo fileInfo = new(fileURI.LocalPath); RotateFlipType rotateFlipType = ImageMethods.GetRotateFlipType(absolutePath); BitmapImage droppedImage = new(); droppedImage.BeginInit(); @@ -464,9 +470,7 @@ public static async Task OcrAbsoluteFilePathAsync(string absolutePath, I droppedImage.CacheOption = BitmapCacheOption.None; droppedImage.EndInit(); droppedImage.Freeze(); - Bitmap bmp = ImageMethods.BitmapImageToBitmap(droppedImage); - language ??= LanguageUtilities.GetCurrentInputLanguage(); - return GetStringFromOcrOutputs(await GetTextFromImageAsync(bmp, language)); + return ImageMethods.BitmapImageToBitmap(droppedImage); } public static async Task GetClickedWordAsync(Window passedWindow, Point clickedPoint, ILanguage OcrLang) @@ -564,7 +568,14 @@ public static async Task OcrFile(string path, ILanguage? selectedLanguag returnString.AppendLine(Path.GetFileName(path)); try { - string ocrText = await OcrAbsoluteFilePathAsync(path, selectedLanguage); + string ocrText; + if (options.GrabTemplate is GrabTemplate grabTemplate) + { + Bitmap bmp = LoadBitmapFromFile(path); + ocrText = await GrabTemplateExecutor.ExecuteTemplateOnBitmapAsync(grabTemplate, bmp, selectedLanguage); + } + else + ocrText = await OcrAbsoluteFilePathAsync(path, selectedLanguage); if (!string.IsNullOrWhiteSpace(ocrText)) { @@ -591,16 +602,17 @@ public static async Task OcrFile(string path, ILanguage? selectedLanguag [GeneratedRegex(@"(^[\p{L}-[\p{Lo}]]|\p{Nd}$)|.{2,}")] private static partial Regex SpaceJoiningWordRegex(); - private static bool CanReplayPreviousFullscreenSelection(HistoryInfo history) + private static async Task CanReplayPreviousFullscreenSelection(HistoryInfo history) { if (history.SelectionStyle is FsgSelectionStyle.Region or FsgSelectionStyle.AdjustAfter) return true; - MessageBox.Show( - "Repeat previous fullscreen capture is currently available only for Region and Adjust After selections.", - "Text Grab", - MessageBoxButton.OK, - MessageBoxImage.Information); + await new Wpf.Ui.Controls.MessageBox + { + Title = "Text Grab", + Content = "Repeat previous fullscreen capture is currently available only for Region and Adjust After selections.", + CloseButtonText = "OK" + }.ShowDialogAsync(); return false; } } diff --git a/Text-Grab/Views/EditTextWindow.xaml b/Text-Grab/Views/EditTextWindow.xaml index 27648940..335f9357 100644 --- a/Text-Grab/Views/EditTextWindow.xaml +++ b/Text-Grab/Views/EditTextWindow.xaml @@ -487,6 +487,9 @@ Click="ReadFolderOfImages_Click" Header="_Extract Text from Images in Folder..." /> + () + .FirstOrDefault(m => m.IsChecked && m.Tag is GrabTemplate) + ?.Tag as GrabTemplate; + + grabTemplateMenuItem.Items.Clear(); + + MenuItem noneItem = new() + { + Header = "(None)", + IsCheckable = true, + IsChecked = previouslySelected is null, + }; + noneItem.Click += GrabTemplateMenuItem_Click; + grabTemplateMenuItem.Items.Add(noneItem); + + foreach (GrabTemplate template in GrabTemplateManager.GetAllTemplates()) + { + MenuItem templateMenuItem = new() + { + Header = template.Name, + IsCheckable = true, + IsChecked = previouslySelected?.Id == template.Id, + Tag = template, + }; + templateMenuItem.Click += GrabTemplateMenuItem_Click; + grabTemplateMenuItem.Items.Add(templateMenuItem); + } + } + + private void GrabTemplateMenuItem_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem clickedItem) + return; + + foreach (MenuItem item in GrabTemplateMenuItem.Items) + item.IsChecked = false; + + clickedItem.IsChecked = true; + } + private void LaunchFindAndReplace() { FindAndReplaceWindow findAndReplaceWindow = WindowUtilities.OpenOrActivateWindow(); @@ -1259,6 +1313,7 @@ private async void LoadLanguageMenuItems(MenuItem captureMenuItem) bool usingTesseract = DefaultSettings.UseTesseract && TesseractHelper.CanLocateTesseractExe(); List availableLanguages = await CaptureLanguageUtilities.GetCaptureLanguagesAsync(usingTesseract); + availableLanguages = availableLanguages.Where(CaptureLanguageUtilities.IsStaticImageCompatible).ToList(); int selectedIndex = CaptureLanguageUtilities.FindPreferredLanguageIndex( availableLanguages, DefaultSettings.LastUsedLang, @@ -1781,6 +1836,16 @@ private async void ReadFolderOfImages_Click(object sender, RoutedEventArgs e) string chosenFolderPath = folderBrowserDialog.SelectedPath; + GrabTemplate? selectedTemplate = null; + foreach (MenuItem item in GrabTemplateMenuItem.Items) + { + if (item.IsChecked && item.Tag is GrabTemplate grabTemplate) + { + selectedTemplate = grabTemplate; + break; + } + } + OcrDirectoryOptions ocrDirectoryOptions = new() { Path = chosenFolderPath, @@ -1788,7 +1853,8 @@ private async void ReadFolderOfImages_Click(object sender, RoutedEventArgs e) WriteTxtFiles = ReadFolderOfImagesWriteTxtFiles.IsChecked is true, OutputFileNames = OutputFilenamesCheck.IsChecked is true, OutputFooter = OutputFooterCheck.IsChecked is true, - OutputHeader = OutputHeaderCheck.IsChecked is true + OutputHeader = OutputHeaderCheck.IsChecked is true, + GrabTemplate = selectedTemplate, }; if (Directory.Exists(chosenFolderPath)) @@ -2162,13 +2228,18 @@ private void SplitOnSelectionCmdCanExecute(object sender, CanExecuteRoutedEventA e.CanExecute = true; } - private void SplitOnSelectionCmdExecuted(object sender, ExecutedRoutedEventArgs e) + private async void SplitOnSelectionCmdExecuted(object sender, ExecutedRoutedEventArgs e) { string selectedText = PassedTextControl.SelectedText; if (string.IsNullOrEmpty(selectedText)) { - System.Windows.MessageBox.Show("No text selected", "Did not split lines"); + await new Wpf.Ui.Controls.MessageBox + { + Title = "Did not split lines", + Content = "No text selected", + CloseButtonText = "OK" + }.ShowDialogAsync(); return; } @@ -2179,13 +2250,18 @@ private void SplitOnSelectionCmdExecuted(object sender, ExecutedRoutedEventArgs PassedTextControl.Text = textToManipulate.ToString(); } - private void SplitAfterSelectionCmdExecuted(object sender, ExecutedRoutedEventArgs e) + private async void SplitAfterSelectionCmdExecuted(object sender, ExecutedRoutedEventArgs e) { string selectedText = PassedTextControl.SelectedText; if (string.IsNullOrEmpty(selectedText)) { - System.Windows.MessageBox.Show("No text selected", "Did not split lines"); + await new Wpf.Ui.Controls.MessageBox + { + Title = "Did not split lines", + Content = "No text selected", + CloseButtonText = "OK" + }.ShowDialogAsync(); return; } @@ -3346,8 +3422,12 @@ private async Task PerformTranslationAsync(string targetLanguage) } catch (Exception ex) { - System.Windows.MessageBox.Show($"Translation failed: {ex.Message}", - "Translation Error", MessageBoxButton.OK, MessageBoxImage.Warning); + await new Wpf.Ui.Controls.MessageBox + { + Title = "Translation Error", + Content = $"Translation failed: {ex.Message}", + CloseButtonText = "OK" + }.ShowDialogAsync(); } finally { @@ -3361,8 +3441,12 @@ private async void ExtractRegexMenuItem_Click(object sender, RoutedEventArgs e) if (string.IsNullOrWhiteSpace(textDescription)) { - System.Windows.MessageBox.Show("Please enter or select text to extract a regex pattern from.", - "No Text", MessageBoxButton.OK, MessageBoxImage.Information); + await new Wpf.Ui.Controls.MessageBox + { + Title = "No Text", + Content = "Please enter or select text to extract a regex pattern from.", + CloseButtonText = "OK" + }.ShowDialogAsync(); return; } @@ -3376,8 +3460,12 @@ private async void ExtractRegexMenuItem_Click(object sender, RoutedEventArgs e) catch (Exception ex) { Debug.WriteLine($"Regex extraction exception: {ex.Message}"); - System.Windows.MessageBox.Show($"An error occurred while extracting regex: {ex.Message}", - "Error", MessageBoxButton.OK, MessageBoxImage.Error); + await new Wpf.Ui.Controls.MessageBox + { + Title = "Error", + Content = $"An error occurred while extracting regex: {ex.Message}", + CloseButtonText = "OK" + }.ShowDialogAsync(); SetToLoaded(); return; } @@ -3386,8 +3474,12 @@ private async void ExtractRegexMenuItem_Click(object sender, RoutedEventArgs e) if (string.IsNullOrWhiteSpace(regexPattern)) { - System.Windows.MessageBox.Show("Failed to extract a regex pattern. The AI service may not be available or could not generate a pattern.", - "Extraction Failed", MessageBoxButton.OK, MessageBoxImage.Warning); + await new Wpf.Ui.Controls.MessageBox + { + Title = "Extraction Failed", + Content = "Failed to extract a regex pattern. The AI service may not be available or could not generate a pattern.", + CloseButtonText = "OK" + }.ShowDialogAsync(); return; } @@ -3428,8 +3520,12 @@ private async void ExtractRegexMenuItem_Click(object sender, RoutedEventArgs e) catch (Exception ex) { Debug.WriteLine($"Failed to copy regex to clipboard: {ex.Message}"); - System.Windows.MessageBox.Show("Failed to copy regex pattern to clipboard.", - "Copy Failed", MessageBoxButton.OK, MessageBoxImage.Error); + await new Wpf.Ui.Controls.MessageBox + { + Title = "Copy Failed", + Content = "Failed to copy regex pattern to clipboard.", + CloseButtonText = "OK" + }.ShowDialogAsync(); } } } From b910692658b475860dca1dade1372c1bb327da8e Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 18 Mar 2026 22:44:24 -0500 Subject: [PATCH 091/109] fix: allow grab templates to appear in user-defined order in post grab menu Remove forced separation of regular and template actions that always pushed grab templates to the bottom with a separator. Actions now render in the user's configured order while template mutual exclusivity is preserved. Co-Authored-By: Claude Sonnet 4.6 --- Text-Grab/Views/FullscreenGrab.xaml.cs | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/Text-Grab/Views/FullscreenGrab.xaml.cs b/Text-Grab/Views/FullscreenGrab.xaml.cs index 37d87831..ca31b7c9 100644 --- a/Text-Grab/Views/FullscreenGrab.xaml.cs +++ b/Text-Grab/Views/FullscreenGrab.xaml.cs @@ -468,23 +468,12 @@ private void LoadDynamicPostGrabActions() contextMenu.PreviewKeyDown -= FullscreenGrab_KeyDown; contextMenu.PreviewKeyDown += FullscreenGrab_KeyDown; - List regularActions = [.. enabledActions.Where(a => string.IsNullOrEmpty(a.TemplateId))]; - List templateActions = [.. enabledActions.Where(a => !string.IsNullOrEmpty(a.TemplateId))]; bool templatePreselected = !string.IsNullOrEmpty(PreselectedTemplateId); int index = 1; - foreach (ButtonInfo action in regularActions) + foreach (ButtonInfo action in enabledActions) { - AddPostGrabActionMenuItem(contextMenu, action, PostGrabActionManager.GetCheckState(action), stayOpen, index); - index++; - } - - if (regularActions.Count > 0 && templateActions.Count > 0) - contextMenu.Items.Add(new Separator()); - - foreach (ButtonInfo action in templateActions) - { - bool isChecked = templatePreselected + bool isChecked = !string.IsNullOrEmpty(action.TemplateId) && templatePreselected ? action.TemplateId == PreselectedTemplateId : PostGrabActionManager.GetCheckState(action); From 67914dab3b652e4305df29e22b8c01f349f7d071 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Wed, 18 Mar 2026 23:38:25 -0500 Subject: [PATCH 092/109] Add template selection & overlay to GrabFrame UI Replaces the "Save as Template" toggle with a DropDownButton for creating or selecting grab templates. Selected templates display region overlays on the capture area, with referenced regions highlighted. Updates grab logic to use the active template for OCR and records template usage. Refactors template management, region highlighting, and output handling for improved workflow. --- .claude/settings.local.json | 3 +- Text-Grab/Views/GrabFrame.xaml | 25 +++-- Text-Grab/Views/GrabFrame.xaml.cs | 156 +++++++++++++++++++++++++++--- 3 files changed, 161 insertions(+), 23 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3d297a84..448bb646 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -11,7 +11,8 @@ "Bash(dotnet build:*)", "Bash(dotnet test:*)", "Bash(gh pr list:*)", - "Bash(/mnt/c/Program Files/dotnet/dotnet.exe:*)" + "Bash(/mnt/c/Program Files/dotnet/dotnet.exe:*)", + "WebFetch(domain:github.com)" ], "deny": [] } diff --git a/Text-Grab/Views/GrabFrame.xaml b/Text-Grab/Views/GrabFrame.xaml index 7e43a8f4..70cfd87d 100644 --- a/Text-Grab/Views/GrabFrame.xaml +++ b/Text-Grab/Views/GrabFrame.xaml @@ -537,6 +537,11 @@ + @@ -905,16 +910,20 @@ ToolTip="Open Text Grab Settings" Visibility="Collapsed" /> - - - + Padding="0" + ToolTip="Select or create a Grab Template"> + + + + + + + ParsePatternMatchesFromTemplate(string private void SaveTemplateCancel_Click(object sender, RoutedEventArgs e) { - SaveAsTemplateBTN.IsChecked = false; TemplateSavePanel.Visibility = Visibility.Collapsed; UpdateTemplateBadges(); } + private void TemplateContextMenu_Opened(object sender, RoutedEventArgs e) + { + if (sender is not ContextMenu menu) return; + menu.Items.Clear(); + + MenuItem createItem = new() { Header = "Create new Grab Template..." }; + createItem.Click += (_, _) => + { + bool show = TemplateSavePanel.Visibility != Visibility.Visible; + TemplateSavePanel.Visibility = show ? Visibility.Visible : Visibility.Collapsed; + if (show) + { + if (!IsFreezeMode) { FreezeToggleButton.IsChecked = true; FreezeGrabFrame(); } + TemplateNameBox.Focus(); + } + UpdateTemplateBadges(); + }; + menu.Items.Add(createItem); + + List templates = GrabTemplateManager.GetAllTemplates(); + if (templates.Count > 0) + { + menu.Items.Add(new Separator()); + foreach (GrabTemplate template in templates) + { + MenuItem item = new() + { + Header = template.Name, + IsCheckable = true, + IsChecked = _activeGrabTemplate?.Id == template.Id, + StaysOpenOnClick = false, + Tag = template.Id, + }; + item.Click += TemplateMenuItem_Click; + menu.Items.Add(item); + } + } + } + + private void TemplateMenuItem_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem item || item.Tag is not string templateId) return; + + if (_activeGrabTemplate?.Id == templateId) + { + _activeGrabTemplate = null; + } + else + { + _activeGrabTemplate = GrabTemplateManager.GetTemplateById(templateId); + } + + UpdateTemplateButtonHighlight(); + UpdateTemplateRegionOverlay(); + } + + private void UpdateTemplateButtonHighlight() + { + TemplateMenuButton.Background = _activeGrabTemplate is not null + ? (System.Windows.Media.Brush)FindResource("AccentButtonBackground") + : System.Windows.Media.Brushes.Transparent; + } + + private void UpdateTemplateRegionOverlay() + { + TemplateRegionOverlayCanvas.Children.Clear(); + + if (_activeGrabTemplate is null || _activeGrabTemplate.Regions.Count == 0) + return; + + double canvasWidth = RectanglesCanvas.ActualWidth; + double canvasHeight = RectanglesCanvas.ActualHeight; + if (canvasWidth < 4 || canvasHeight < 4) + return; + + HashSet referencedRegions = [.. _activeGrabTemplate.GetReferencedRegionNumbers()]; + if (referencedRegions.Count == 0 && _activeGrabTemplate.PatternMatches.Count > 0) + return; + + System.Windows.Media.Color borderColor = System.Windows.Media.Color.FromArgb(220, 255, 180, 0); + System.Windows.Media.Color dimBorderColor = System.Windows.Media.Color.FromArgb(80, 255, 180, 0); + + foreach (TemplateRegion region in _activeGrabTemplate.Regions) + { + double regionLeft = region.RatioLeft * canvasWidth; + double regionTop = region.RatioTop * canvasHeight; + double regionWidth = region.RatioWidth * canvasWidth; + double regionHeight = region.RatioHeight * canvasHeight; + + if (regionWidth < 1 || regionHeight < 1) continue; + + bool isReferenced = referencedRegions.Count == 0 || referencedRegions.Contains(region.RegionNumber); + Border regionBorder = new() + { + Width = regionWidth, + Height = regionHeight, + BorderBrush = new SolidColorBrush(isReferenced ? borderColor : dimBorderColor), + BorderThickness = new Thickness(1.5), + }; + Canvas.SetLeft(regionBorder, regionLeft); + Canvas.SetTop(regionBorder, regionTop); + TemplateRegionOverlayCanvas.Children.Add(regionBorder); + } + } + private void UpdateTemplateBadges() { - bool isTemplateMode = SaveAsTemplateBTN.IsChecked == true; + bool isTemplateMode = TemplateSavePanel.Visibility == Visibility.Visible; if (!isTemplateMode) { @@ -2981,7 +3084,7 @@ private void UpdateTemplateBadges() private void UpdateTemplateRegionOpacities() { - if (SaveAsTemplateBTN.IsChecked != true) + if (TemplateSavePanel.Visibility != Visibility.Visible) return; string outputTemplate = TemplateOutputBox.GetSerializedText(); @@ -3273,10 +3376,13 @@ private void UpdateFrameText() if (IsFromEditWindow && destinationTextBox is not null && AlwaysUpdateEtwCheckBox.IsChecked is true - && EditTextToggleButton.IsChecked is true) + && EditTextToggleButton.IsChecked is true + && _activeGrabTemplate is null) { destinationTextBox.SelectedText = FrameText; } + + UpdateTemplateRegionOverlay(); } private void Window_Closed(object? sender, EventArgs e) @@ -3364,15 +3470,37 @@ private void CanExecuteGrab(object sender, CanExecuteRoutedEventArgs e) e.CanExecute = true; } - private void GrabExecuted(object sender, ExecutedRoutedEventArgs e) + private async void GrabExecuted(object sender, ExecutedRoutedEventArgs e) { - if (string.IsNullOrWhiteSpace(FrameText)) + string outputText = FrameText; + + if (_activeGrabTemplate is not null) + { + if (isStaticImageSource && frameContentImageSource is BitmapSource bmpSrc) + { + using System.Drawing.Bitmap bmp = ImageMethods.BitmapSourceToBitmap(bmpSrc); + outputText = await GrabTemplateExecutor.ExecuteTemplateOnBitmapAsync( + _activeGrabTemplate, bmp, CurrentLanguage); + } + else + { + System.Drawing.Rectangle screenRect = GetContentAreaScreenRect(); + Rect captureRect = new(screenRect.X, screenRect.Y, screenRect.Width, screenRect.Height); + outputText = await GrabTemplateExecutor.ExecuteTemplateAsync( + _activeGrabTemplate, captureRect, CurrentLanguage); + } + + if (!string.IsNullOrWhiteSpace(outputText)) + GrabTemplateManager.RecordUsage(_activeGrabTemplate.Id); + } + + if (string.IsNullOrWhiteSpace(outputText)) return; if (destinationTextBox is not null) { - if (AlwaysUpdateEtwCheckBox.IsChecked is false) - destinationTextBox.SelectedText = FrameText; + if (_activeGrabTemplate is not null || AlwaysUpdateEtwCheckBox.IsChecked is false) + destinationTextBox.SelectedText = outputText; destinationTextBox.Select(destinationTextBox.SelectionStart + destinationTextBox.SelectionLength, 0); destinationTextBox.AppendText(Environment.NewLine); @@ -3384,10 +3512,10 @@ private void GrabExecuted(object sender, ExecutedRoutedEventArgs e) } if (!DefaultSettings.NeverAutoUseClipboard) - try { Clipboard.SetDataObject(FrameText, true); } catch { } + try { Clipboard.SetDataObject(outputText, true); } catch { } if (DefaultSettings.ShowToast) - NotificationUtilities.ShowToast(FrameText); + NotificationUtilities.ShowToast(outputText); if (CloseOnGrabMenuItem.IsChecked) Close(); From 868bf70040dfc3be2c5027b094552e69ca85cf58 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Thu, 19 Mar 2026 07:22:06 -0500 Subject: [PATCH 093/109] Update NuGet package versions in main and test projects Upgraded Humanizer.Core, Magick.NET, and Microsoft.WindowsAppSDK packages in Text-Grab.csproj. Updated coverlet.collector and BenchmarkDotNetDiagnosers in Tests.csproj to latest versions for improved compatibility and features. --- Tests/Tests.csproj | 4 ++-- Text-Grab/Text-Grab.csproj | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index eb6c0bf8..a37da1a3 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -13,7 +13,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -27,7 +27,7 @@ - + diff --git a/Text-Grab/Text-Grab.csproj b/Text-Grab/Text-Grab.csproj index 23ed5387..a045924b 100644 --- a/Text-Grab/Text-Grab.csproj +++ b/Text-Grab/Text-Grab.csproj @@ -53,15 +53,15 @@ - - - - + + + + - - - - + + + + From 79461483105b6b36eb1885d34833bad3cdfc941d Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Thu, 19 Mar 2026 23:00:51 -0500 Subject: [PATCH 094/109] fix: use stored image for OCR when GrabFrame is frozen When a GrabFrame is frozen and the user clicks Refresh or resizes, OCR now runs on the stored image instead of re-capturing the screen (which included letterbox backgrounds, word borders, and overlays). Also fixes word border misalignment after resize by using image-relative coordinates for frozen/loaded frames. Co-Authored-By: Claude Opus 4.6 --- Text-Grab/Utilities/OcrUtilities.cs | 16 +++++++++++++++ Text-Grab/Views/GrabFrame.xaml.cs | 32 ++++++++++++++++++----------- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/Text-Grab/Utilities/OcrUtilities.cs b/Text-Grab/Utilities/OcrUtilities.cs index a908826c..7652a5cf 100644 --- a/Text-Grab/Utilities/OcrUtilities.cs +++ b/Text-Grab/Utilities/OcrUtilities.cs @@ -231,6 +231,22 @@ public static async Task GetTextFromBitmapSourceAsTableAsync(BitmapSourc } + public static async Task<(IOcrLinesWords?, double)> GetOcrResultFromBitmapAsync(Bitmap bmp, ILanguage language) + { + language = GetCompatibleOcrLanguage(language); + + if (language is WindowsAiLang) + return (await WindowsAiUtilities.GetOcrResultAsync(bmp), 1.0); + + if (language is not GlobalLang globalLang) + globalLang = new GlobalLang(language.LanguageTag); + + double scale = await GetIdealScaleFactorForOcrAsync(bmp, language); + using Bitmap scaledBitmap = ImageMethods.ScaleBitmapUniform(bmp, scale); + IOcrLinesWords ocrResult = await GetOcrResultFromImageAsync(scaledBitmap, globalLang); + return (ocrResult, scale); + } + public static async Task GetOcrResultFromImageAsync(SoftwareBitmap scaledBitmap, ILanguage language) { language = GetCompatibleOcrLanguage(language); diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index 56a030b6..d61125a4 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -1398,8 +1398,15 @@ private async Task DrawOcrRectanglesAsync(string searchWord = "") if (ocrResultOfWindow is null || ocrResultOfWindow.Lines.Length == 0) { - ILanguage lang = CurrentLanguage ?? LanguageUtilities.GetCurrentInputLanguage(); - (ocrResultOfWindow, windowFrameImageScale) = await OcrUtilities.GetOcrResultFromRegionAsync(rectCanvasSize, CurrentLanguage); + if (frameContentImageSource is BitmapSource frozenBmp) + { + using System.Drawing.Bitmap bmpForOcr = ImageMethods.BitmapSourceToBitmap(frozenBmp); + (ocrResultOfWindow, windowFrameImageScale) = await OcrUtilities.GetOcrResultFromBitmapAsync(bmpForOcr, CurrentLanguage); + } + else + { + (ocrResultOfWindow, windowFrameImageScale) = await OcrUtilities.GetOcrResultFromRegionAsync(rectCanvasSize, CurrentLanguage); + } } if (ocrResultOfWindow is null) @@ -1414,7 +1421,7 @@ private async Task DrawOcrRectanglesAsync(string searchWord = "") System.Drawing.Bitmap? bmp = null; bool shouldDisposeBmp = false; - if (isStaticImageSource && frameContentImageSource is BitmapSource bmpImg) + if (frameContentImageSource is BitmapSource bmpImg) { bmp = ImageMethods.BitmapSourceToBitmap(bmpImg); shouldDisposeBmp = true; @@ -1426,7 +1433,9 @@ private async Task DrawOcrRectanglesAsync(string searchWord = "") } int lineNumber = 0; - (double viewBoxZoomFactor, double borderToCanvasX, double borderToCanvasY) = GetOverlayRenderMetrics(); + bool useImageCoords = frameContentImageSource is not null; + (double viewBoxZoomFactor, double borderToCanvasX, double borderToCanvasY) = + useImageCoords ? (1.0, 0.0, 0.0) : GetOverlayRenderMetrics(); foreach (IOcrLine ocrLine in ocrResultOfWindow.Lines) { @@ -1518,7 +1527,7 @@ private async Task DrawUiAutomationRectanglesAsync(string searchWord = "") } UiAutomationOverlaySnapshot? overlaySnapshot = null; - if (isStaticImageSource && frozenUiAutomationSnapshot is not null) + if ((isStaticImageSource || IsFreezeMode) && frozenUiAutomationSnapshot is not null) { overlaySnapshot = frozenUiAutomationSnapshot; } @@ -1553,7 +1562,9 @@ private async Task DrawUiAutomationRectanglesAsync(string searchWord = "") shouldDisposeBmp = true; } - (double viewBoxZoomFactor, double borderToCanvasX, double borderToCanvasY) = GetOverlayRenderMetrics(); + bool useImageCoords = frameContentImageSource is not null; + (double viewBoxZoomFactor, double borderToCanvasX, double borderToCanvasY) = + useImageCoords ? (1.0, 0.0, 0.0) : GetOverlayRenderMetrics(); Rect sourceBounds = overlaySnapshot.CaptureBounds; int lineNumber = 0; @@ -2580,10 +2591,10 @@ private async void RefreshBTN_Click(object? sender = null, RoutedEventArgs? e = GrabFrameCanvas = RectanglesCanvas }); - if (hasLoadedImageSource) + if (hasLoadedImageSource || IsFreezeMode) { - // For loaded images, clear OCR results and re-run OCR on the same image. - // Zoom must be reset because the screen-capture-based OCR pipeline + // For loaded or frozen images, clear OCR results and re-run OCR on the same stored image. + // Zoom must be reset because the OCR pipeline // calculates word border positions assuming no zoom transform. MainZoomBorder.Reset(); RectanglesCanvas.RenderTransform = Transform.Identity; @@ -2592,9 +2603,6 @@ private async void RefreshBTN_Click(object? sender = null, RoutedEventArgs? e = ClearRenderedWordBorders(); MatchesTXTBLK.Text = "- Matches"; UpdateFrameText(); - - // Allow WPF to repaint the unzoomed view before screen-capture OCR - await Task.Delay(200); } else { From cbead0ad154bce51275446858b534801e744e29f Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Thu, 19 Mar 2026 23:23:42 -0500 Subject: [PATCH 095/109] feat: add ZoomWhenFrozen scroll mode and fix PresentationSource crash on close Add a new scroll behavior that resizes when the GrabFrame is live and zooms via ZoomBorder when frozen. Also guard GetContentAreaScreenRect() against the InvalidOperationException thrown when PointToScreen is called after the window has been disconnected from its PresentationSource. Co-Authored-By: Claude Sonnet 4.6 --- Text-Grab/Enums.cs | 1 + Text-Grab/Utilities/ImageMethods.cs | 2 ++ Text-Grab/Views/GrabFrame.xaml | 6 ++++++ Text-Grab/Views/GrabFrame.xaml.cs | 25 ++++++++++++++++++++++++- 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/Text-Grab/Enums.cs b/Text-Grab/Enums.cs index 14cf7084..52824fa6 100644 --- a/Text-Grab/Enums.cs +++ b/Text-Grab/Enums.cs @@ -83,6 +83,7 @@ public enum ScrollBehavior None = 0, Resize = 1, Zoom = 2, + ZoomWhenFrozen = 3, } public enum LanguageKind diff --git a/Text-Grab/Utilities/ImageMethods.cs b/Text-Grab/Utilities/ImageMethods.cs index d569cb20..2c181a9b 100644 --- a/Text-Grab/Utilities/ImageMethods.cs +++ b/Text-Grab/Utilities/ImageMethods.cs @@ -125,6 +125,8 @@ public static Bitmap GetWindowsBoundsBitmap(Window passedWindow) // transparent content area. This is always correct regardless of DPI, // border thickness, or title/bottom bar heights. Rectangle contentRect = grabFrame.GetContentAreaScreenRect(); + if (contentRect == Rectangle.Empty) + return new Bitmap(1, 1); thisCorrectedLeft = contentRect.X; thisCorrectedTop = contentRect.Y; windowWidth = contentRect.Width; diff --git a/Text-Grab/Views/GrabFrame.xaml b/Text-Grab/Views/GrabFrame.xaml index 70cfd87d..8717729c 100644 --- a/Text-Grab/Views/GrabFrame.xaml +++ b/Text-Grab/Views/GrabFrame.xaml @@ -349,6 +349,12 @@ Header="Scroll Zooms Grab Frame (preview)" IsCheckable="True" Tag="Zoom" /> + internal System.Drawing.Rectangle GetContentAreaScreenRect() { + if (PresentationSource.FromVisual(this) is null) + return System.Drawing.Rectangle.Empty; + DpiScale dpi = VisualTreeHelper.GetDpi(this); Point topLeft = RectanglesBorder.PointToScreen(new Point(0, 0)); return new System.Drawing.Rectangle( @@ -1744,6 +1747,9 @@ private void FreezeGrabFrame() Background = new SolidColorBrush(Colors.DimGray); RectanglesBorder.Background.Opacity = 0; IsFreezeMode = true; + + if (scrollBehavior == ScrollBehavior.ZoomWhenFrozen) + MainZoomBorder.CanZoom = true; } private void SyncRectanglesCanvasSizeToImage() @@ -2086,6 +2092,9 @@ private void HandlePreviewMouseWheel(object sender, MouseWheelEventArgs e) return; } + if (scrollBehavior == ScrollBehavior.ZoomWhenFrozen && IsFreezeMode) + return; // ZoomBorder handles scroll when frozen + e.Handled = true; double aspectRatio = (Height - 66) / (Width - 4); @@ -3021,7 +3030,7 @@ private void TemplateMenuItem_Click(object sender, RoutedEventArgs e) private void UpdateTemplateButtonHighlight() { TemplateMenuButton.Background = _activeGrabTemplate is not null - ? (System.Windows.Media.Brush)FindResource("AccentButtonBackground") + ? (System.Windows.Media.Brush)FindResource("DarkTeal") : System.Windows.Media.Brushes.Transparent; } @@ -3352,6 +3361,10 @@ private void UnfreezeGrabFrame() FreezeToggleButton.Visibility = Visibility.Visible; Background = new SolidColorBrush(Colors.Transparent); IsFreezeMode = false; + + if (scrollBehavior == ScrollBehavior.ZoomWhenFrozen) + MainZoomBorder.CanZoom = false; + reDrawTimer.Start(); } @@ -3578,20 +3591,30 @@ private void SetScrollBehaviorMenuItems() NoScrollBehaviorMenuItem.IsChecked = true; ResizeScrollMenuItem.IsChecked = false; ZoomScrollMenuItem.IsChecked = false; + ZoomWhenFrozenScrollMenuItem.IsChecked = false; MainZoomBorder.CanZoom = false; break; case ScrollBehavior.Resize: NoScrollBehaviorMenuItem.IsChecked = false; ResizeScrollMenuItem.IsChecked = true; ZoomScrollMenuItem.IsChecked = false; + ZoomWhenFrozenScrollMenuItem.IsChecked = false; MainZoomBorder.CanZoom = false; break; case ScrollBehavior.Zoom: NoScrollBehaviorMenuItem.IsChecked = false; ResizeScrollMenuItem.IsChecked = false; ZoomScrollMenuItem.IsChecked = true; + ZoomWhenFrozenScrollMenuItem.IsChecked = false; MainZoomBorder.CanZoom = true; break; + case ScrollBehavior.ZoomWhenFrozen: + NoScrollBehaviorMenuItem.IsChecked = false; + ResizeScrollMenuItem.IsChecked = false; + ZoomScrollMenuItem.IsChecked = false; + ZoomWhenFrozenScrollMenuItem.IsChecked = true; + MainZoomBorder.CanZoom = IsFreezeMode; + break; default: break; } From 0371720d6c589a86715ee25b6ce43f08d5a707ca Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Thu, 19 Mar 2026 23:33:47 -0500 Subject: [PATCH 096/109] fix: pan teal selection border with region and draw it outside captured area - PanSelection now calls UpdateSelectionOutline so the teal border moves with the selection when Shift is held - UpdateSelectionOutline offsets selectionOutlineBorder outward by its border thickness so the border sits outside the selection rect and is not included in the captured/OCR'd image Co-Authored-By: Claude Sonnet 4.6 --- Text-Grab/Views/FullscreenGrab.SelectionStyles.cs | 9 +++++---- Text-Grab/Views/FullscreenGrab.xaml.cs | 2 ++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs b/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs index a40dc87c..38c3f0c6 100644 --- a/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs +++ b/Text-Grab/Views/FullscreenGrab.SelectionStyles.cs @@ -425,10 +425,11 @@ private void UpdateSelectionOutline(Rect rect, bool shouldShowOutline) } EnsureSelectionOutlineVisible(); - selectionOutlineBorder.Width = Math.Max(0, rect.Width); - selectionOutlineBorder.Height = Math.Max(0, rect.Height); - Canvas.SetLeft(selectionOutlineBorder, rect.Left); - Canvas.SetTop(selectionOutlineBorder, rect.Top); + double t = selectionOutlineBorder.BorderThickness.Left; + selectionOutlineBorder.Width = Math.Max(0, rect.Width + 2 * t); + selectionOutlineBorder.Height = Math.Max(0, rect.Height + 2 * t); + Canvas.SetLeft(selectionOutlineBorder, rect.Left - t); + Canvas.SetTop(selectionOutlineBorder, rect.Top - t); } private void ClearSelectionOutline() diff --git a/Text-Grab/Views/FullscreenGrab.xaml.cs b/Text-Grab/Views/FullscreenGrab.xaml.cs index ca31b7c9..5f808b48 100644 --- a/Text-Grab/Views/FullscreenGrab.xaml.cs +++ b/Text-Grab/Views/FullscreenGrab.xaml.cs @@ -805,6 +805,8 @@ private void PanSelection(System.Windows.Point movingPoint) Canvas.SetLeft(selectBorder, leftValue); Canvas.SetTop(selectBorder, topValue); + Rect panRect = new(leftValue, topValue, selectBorder.Width, selectBorder.Height); + UpdateSelectionOutline(panRect, ShouldDrawSelectionOutline(CurrentSelectionStyle)); UpdateTemplateRegionOverlays(leftValue, topValue, selectBorder.Width, selectBorder.Height); } From cfeb1a3bf039f3b9168d2de253fa920ff0b07166 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Thu, 19 Mar 2026 23:52:19 -0500 Subject: [PATCH 097/109] feat: add Reset View, Show Word Borders, and Overlay Opacity to View menu - Reset View resets zoom/pan via middle-click or menu item - Show Word Borders toggles RectanglesCanvas visibility - Overlay Opacity submenu switches between Off and 5% background tint - overlayOpacity field ensures chosen opacity persists through freeze/unfreeze Co-Authored-By: Claude Sonnet 4.6 --- Text-Grab/Views/GrabFrame.xaml | 28 ++++++++++++++++++++++++++ Text-Grab/Views/GrabFrame.xaml.cs | 33 ++++++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/Text-Grab/Views/GrabFrame.xaml b/Text-Grab/Views/GrabFrame.xaml index 8717729c..78992f65 100644 --- a/Text-Grab/Views/GrabFrame.xaml +++ b/Text-Grab/Views/GrabFrame.xaml @@ -356,6 +356,34 @@ IsCheckable="True" Tag="ZoomWhenFrozen" /> + + + + + + + + wordBorders = []; private static readonly Settings DefaultSettings = AppUtilities.TextGrabSettings; private ScrollBehavior scrollBehavior = ScrollBehavior.Resize; + private double overlayOpacity = 0.05; private bool isTranslationEnabled = false; private string translationTargetLanguage = "English"; private readonly DispatcherTimer translationTimer = new(); @@ -3356,7 +3357,7 @@ private void UnfreezeGrabFrame() GrabFrameImage.Opacity = 0; frameContentImageSource = null; historyItem = null; - RectanglesBorder.Background.Opacity = 0.05; + RectanglesBorder.Background.Opacity = overlayOpacity; FreezeToggleButton.IsChecked = false; FreezeToggleButton.Visibility = Visibility.Visible; Background = new SolidColorBrush(Colors.Transparent); @@ -3483,6 +3484,36 @@ private void CloseOnGrabMenuItem_Click(object sender, RoutedEventArgs e) DefaultSettings.Save(); } + private void ResetViewMenuItem_Click(object sender, RoutedEventArgs e) + { + MainZoomBorder.Reset(); + } + + private void ShowWordBordersMenuItem_Click(object sender, RoutedEventArgs e) + { + RectanglesCanvas.Visibility = ShowWordBordersMenuItem.IsChecked is true + ? Visibility.Visible + : Visibility.Hidden; + } + + private void OverlayOpacityMenuItem_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem clicked + || clicked.Tag is not string tagStr + || !double.TryParse(tagStr, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out double opacity)) + return; + + overlayOpacity = opacity; + + OverlayOpacityOffMenuItem.IsChecked = false; + OverlayOpacityLowMenuItem.IsChecked = false; + clicked.IsChecked = true; + + if (!IsFreezeMode) + RectanglesBorder.Background.Opacity = overlayOpacity; + } + private void CanExecuteGrab(object sender, CanExecuteRoutedEventArgs e) { if (string.IsNullOrEmpty(FrameText)) From cbeec18cd021152cfc3f02bb159860f8cbc74760 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Mon, 23 Mar 2026 00:34:02 -0500 Subject: [PATCH 098/109] fix: sync TemplateRegionOverlayCanvas sizing with RectanglesCanvas Co-Authored-By: Claude Opus 4.6 --- Text-Grab/Views/GrabFrame.xaml | 1 + Text-Grab/Views/GrabFrame.xaml.cs | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/Text-Grab/Views/GrabFrame.xaml b/Text-Grab/Views/GrabFrame.xaml index 78992f65..4d3b4ca6 100644 --- a/Text-Grab/Views/GrabFrame.xaml +++ b/Text-Grab/Views/GrabFrame.xaml @@ -573,6 +573,7 @@ diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index 50d1af33..0003698c 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -1771,12 +1771,14 @@ private void SyncRectanglesCanvasSizeToImage() { GrabFrameImage.Width = sourceWidth; RectanglesCanvas.Width = sourceWidth; + TemplateRegionOverlayCanvas.Width = sourceWidth; } if (double.IsFinite(sourceHeight) && sourceHeight > 0) { GrabFrameImage.Height = sourceHeight; RectanglesCanvas.Height = sourceHeight; + TemplateRegionOverlayCanvas.Height = sourceHeight; } } @@ -2723,6 +2725,8 @@ private void ResetGrabFrame() RectanglesCanvas.RenderTransform = Transform.Identity; RectanglesCanvas.ClearValue(WidthProperty); RectanglesCanvas.ClearValue(HeightProperty); + TemplateRegionOverlayCanvas.ClearValue(WidthProperty); + TemplateRegionOverlayCanvas.ClearValue(HeightProperty); GrabFrameImage.ClearValue(WidthProperty); GrabFrameImage.ClearValue(HeightProperty); IsOcrValid = false; From 456f2fe0486eba02ffeffbeba77e93ddd64ce990 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Mon, 23 Mar 2026 13:48:43 -0500 Subject: [PATCH 099/109] make versions 4.13 --- Text-Grab-Package/Package.appxmanifest | 2 +- Text-Grab/Text-Grab.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Text-Grab-Package/Package.appxmanifest b/Text-Grab-Package/Package.appxmanifest index ae39a9d7..2e5db546 100644 --- a/Text-Grab-Package/Package.appxmanifest +++ b/Text-Grab-Package/Package.appxmanifest @@ -14,7 +14,7 @@ + Version="4.13.0.0" /> Text Grab diff --git a/Text-Grab/Text-Grab.csproj b/Text-Grab/Text-Grab.csproj index 899c8352..7fe08a97 100644 --- a/Text-Grab/Text-Grab.csproj +++ b/Text-Grab/Text-Grab.csproj @@ -23,7 +23,7 @@ true win-x86;win-x64;win-arm64 false - 4.12.1 + 4.13.0 From 70e9e19b5a238a4c87a32a2859204fe879a5f41e Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Mon, 23 Mar 2026 17:06:46 -0500 Subject: [PATCH 100/109] Catch lang name issue --- Text-Grab/Models/GlobalLang.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Text-Grab/Models/GlobalLang.cs b/Text-Grab/Models/GlobalLang.cs index 53229309..ebee655d 100644 --- a/Text-Grab/Models/GlobalLang.cs +++ b/Text-Grab/Models/GlobalLang.cs @@ -27,7 +27,7 @@ public GlobalLang(string inputLangTag) } catch (System.ArgumentException ex) { - System.Diagnostics.Debug.WriteLine($"Failed to initialize language '{inputLang}': {ex.Message}"); + System.Diagnostics.Debug.WriteLine($"Failed to initialize language '{inputLangTag}': {ex.Message}"); // return the language of the keyboard language = new(System.Globalization.CultureInfo.CurrentCulture.Name); } From 07123ed89d52f3b67eca61c7ff636ba0b687dcf3 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Mon, 23 Mar 2026 17:07:39 -0500 Subject: [PATCH 101/109] Read multiple barcodes --- Tests/BarcodeUtilitiesTests.cs | 46 +++++++++- Text-Grab/Utilities/BarcodeUtilities.cs | 40 +++++---- Text-Grab/Utilities/OcrUtilities.cs | 5 +- Text-Grab/Views/GrabFrame.xaml.cs | 115 +++++++++++++++++------- 4 files changed, 149 insertions(+), 57 deletions(-) diff --git a/Tests/BarcodeUtilitiesTests.cs b/Tests/BarcodeUtilitiesTests.cs index 169f8e67..d4856aff 100644 --- a/Tests/BarcodeUtilitiesTests.cs +++ b/Tests/BarcodeUtilitiesTests.cs @@ -5,22 +5,60 @@ using Text_Grab; using Text_Grab.Models; using Text_Grab.Utilities; +using UnitsNet; using Windows.Storage.Streams; +using static System.Net.Mime.MediaTypeNames; namespace Tests; public class BarcodeUtilitiesTests { [Fact] - public void TryToReadBarcodes_WithDisposedBitmap_ReturnsEmptyBarcodeOutput() + public void TryToReadBarcodes_WithDisposedBitmap_ReturnsEmptyList() { Bitmap disposedBitmap = new(8, 8); disposedBitmap.Dispose(); - OcrOutput result = BarcodeUtilities.TryToReadBarcodes(disposedBitmap); + List results = BarcodeUtilities.TryToReadBarcodes(disposedBitmap); - Assert.Equal(OcrOutputKind.Barcode, result.Kind); - Assert.Equal(string.Empty, result.RawOutput); + Assert.Empty(results); + } + + [Fact] + public void TryToReadBarcodes_WithTwoQrCodes_ReturnsTwoResults() + { + // Build a side-by-side bitmap containing two different QR codes + using Bitmap qr1 = BarcodeUtilities.GetQrCodeForText("https://example.com", ZXing.QrCode.Internal.ErrorCorrectionLevel.M); + using Bitmap qr2 = BarcodeUtilities.GetQrCodeForText("https://example.org", ZXing.QrCode.Internal.ErrorCorrectionLevel.M); + + using Bitmap combined = new(qr1.Width + qr2.Width, Math.Max(qr1.Height, qr2.Height)); + using (Graphics g = Graphics.FromImage(combined)) + { + g.Clear(Color.White); + g.DrawImage(qr1, 0, 0); + g.DrawImage(qr2, qr1.Width, 0); + } + + List results = BarcodeUtilities.TryToReadBarcodes(combined); + + Assert.Equal(2, results.Count); + Assert.All(results, r => Assert.Equal(OcrOutputKind.Barcode, r.Kind)); + Assert.Contains(results, r => r.RawOutput == "https://example.com"); + Assert.Contains(results, r => r.RawOutput == "https://example.org"); + } + + [WpfFact] + public void ReadTestSingleQRCode() + { + string expectedOutput = "This is a test of the QR Code system"; + string testFilePath = FileUtilities.GetPathToLocalFile(@".\Images\QrCodeTestImage.png"); + + Bitmap testBmp = new(testFilePath); + + List result = BarcodeUtilities.TryToReadBarcodes(testBmp); + + Assert.Single(result); + Assert.Equal(expectedOutput, result[0].RawOutput); } [Fact] diff --git a/Text-Grab/Utilities/BarcodeUtilities.cs b/Text-Grab/Utilities/BarcodeUtilities.cs index e0e9ebe0..186c1a1f 100644 --- a/Text-Grab/Utilities/BarcodeUtilities.cs +++ b/Text-Grab/Utilities/BarcodeUtilities.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.Drawing; +using System.Linq; using System.Runtime.InteropServices; using Text_Grab.Models; using ZXing; @@ -14,12 +16,10 @@ namespace Text_Grab.Utilities; public static class BarcodeUtilities { - private static OcrOutput EmptyBarcodeOutput => new() { Kind = OcrOutputKind.Barcode, RawOutput = string.Empty }; - - public static OcrOutput TryToReadBarcodes(Bitmap bitmap) + public static List TryToReadBarcodes(Bitmap bitmap) { if (!CanReadBitmapDimensions(bitmap)) - return EmptyBarcodeOutput; + return []; BarcodeReader barcodeReader = new() { @@ -27,38 +27,40 @@ public static OcrOutput TryToReadBarcodes(Bitmap bitmap) Options = new DecodingOptions { TryHarder = true } }; - Result? result = null; + Result[]? results = null; try { - result = barcodeReader.Decode(bitmap); + results = barcodeReader.DecodeMultiple(bitmap); } catch (ArgumentException ex) { Debug.WriteLine($"Unable to decode barcode from bitmap: {ex.Message}"); - return EmptyBarcodeOutput; + return []; } catch (ObjectDisposedException ex) { Debug.WriteLine($"Unable to decode barcode from disposed bitmap: {ex.Message}"); - return EmptyBarcodeOutput; + return []; } catch (ExternalException ex) { Debug.WriteLine($"Unable to decode barcode from GDI+ bitmap: {ex.Message}"); - return EmptyBarcodeOutput; + return []; } - string resultString = string.Empty; - if (result is not null) - resultString = result.Text; - - return new OcrOutput() - { - Kind = OcrOutputKind.Barcode, - RawOutput = resultString, - SourceBitmap = bitmap, - }; + if (results is null) + return []; + + return results + .Where(r => r?.Text is not null) + .Select(r => new OcrOutput() + { + Kind = OcrOutputKind.Barcode, + RawOutput = r.Text, + SourceBitmap = bitmap, + }) + .ToList(); } private static bool CanReadBitmapDimensions(Bitmap? bitmap) diff --git a/Text-Grab/Utilities/OcrUtilities.cs b/Text-Grab/Utilities/OcrUtilities.cs index 7652a5cf..55864e52 100644 --- a/Text-Grab/Utilities/OcrUtilities.cs +++ b/Text-Grab/Utilities/OcrUtilities.cs @@ -421,10 +421,7 @@ public static async Task> GetTextFromImageAsync(Bitmap bitmap, I } if (DefaultSettings.TryToReadBarcodes) - { - OcrOutput barcodeResult = BarcodeUtilities.TryToReadBarcodes(bitmap); - outputs.Add(barcodeResult); - } + outputs.AddRange(BarcodeUtilities.TryToReadBarcodes(bitmap)); return outputs; } diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index 0003698c..7b3b66af 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -3295,9 +3295,25 @@ private void TryToPlaceTable() private void TryToReadBarcodes(DpiScale dpi) { if (DefaultSettings.GrabFrameReadBarcodes is false) + { + Debug.WriteLine("TryToReadBarcodes: GrabFrameReadBarcodes is disabled, returning early"); return; + } + + System.Drawing.Bitmap? bitmapOfGrabFrame = null; + + if (frameContentImageSource is BitmapSource frameBitmapSource) + { + Debug.WriteLine("reuse frameBitmapSource"); + bitmapOfGrabFrame = ImageMethods.BitmapSourceToBitmap(frameBitmapSource); + } + else + { + Debug.WriteLine("Could not reuse frameBitmapSource"); + bitmapOfGrabFrame = ImageMethods.GetWindowsBoundsBitmap(this); + } - System.Drawing.Bitmap bitmapOfGrabFrame = ImageMethods.GetWindowsBoundsBitmap(this); + Debug.WriteLine($"TryToReadBarcodes: bitmap size = {bitmapOfGrabFrame.Width}x{bitmapOfGrabFrame.Height}, dpi = {dpi.DpiScaleX}x{dpi.DpiScaleY}"); BarcodeReader barcodeReader = new() { @@ -3305,43 +3321,82 @@ private void TryToReadBarcodes(DpiScale dpi) Options = new ZXing.Common.DecodingOptions { TryHarder = true } }; - Result result = barcodeReader.Decode(bitmapOfGrabFrame); + Result[]? results = barcodeReader.DecodeMultiple(bitmapOfGrabFrame); - if (result is null) + if (results is null) + { + Debug.WriteLine("TryToReadBarcodes: DecodeMultiple returned null (no barcodes found)"); return; + } - ResultPoint[] rawPoints = result.ResultPoints; + Debug.WriteLine($"TryToReadBarcodes: DecodeMultiple found {results.Length} result(s)"); - float[] xs = [.. rawPoints.Reverse().Take(4).Select(x => x.X)]; - float[] ys = [.. rawPoints.Reverse().Take(4).Select(x => x.Y)]; + foreach (Result result in results) + { + if (result?.Text is null) + { + Debug.WriteLine("TryToReadBarcodes: skipping result with null Text"); + continue; + } - Point minPoint = new(xs.Min(), ys.Min()); - Point maxPoint = new(xs.Max(), ys.Max()); - Point diffs = new(maxPoint.X - minPoint.X, maxPoint.Y - minPoint.Y); + Debug.WriteLine($"TryToReadBarcodes: result format={result.BarcodeFormat}, text=\"{result.Text}\""); - if (diffs.Y < 5) - diffs.Y = diffs.X / 10; + ResultPoint[] rawPoints = result.ResultPoints; - WordBorder wb = new() - { - Word = result.Text, - Width = diffs.X / dpi.DpiScaleX + 12, - Height = diffs.Y / dpi.DpiScaleY + 12, - Left = minPoint.X / (dpi.DpiScaleX) - 6, - Top = minPoint.Y / (dpi.DpiScaleY) - 6, - OwnerGrabFrame = this - }; - wb.SetAsBarcode(); - wordBorders.Add(wb); - _ = RectanglesCanvas.Children.Add(wb); + if (rawPoints is null || rawPoints.Length == 0) + { + Debug.WriteLine("TryToReadBarcodes: rawPoints is null or empty, skipping"); + continue; + } - UndoRedo.InsertUndoRedoOperation(UndoRedoOperation.AddWordBorder, - new GrabFrameOperationArgs() - { - WordBorder = wb, - WordBorders = wordBorders, - GrabFrameCanvas = RectanglesCanvas - }); + Debug.WriteLine($"TryToReadBarcodes: rawPoints count={rawPoints.Length}, null count={rawPoints.Count(p => p is null)}"); + for (int i = 0; i < rawPoints.Length; i++) + Debug.WriteLine($" rawPoints[{i}] = {(rawPoints[i] is null ? "null" : $"({rawPoints[i].X:F1}, {rawPoints[i].Y:F1})")}"); + + ResultPoint[] validPoints = [.. rawPoints.Where(p => p is not null).Reverse().Take(4)]; + float[] xs = [.. validPoints.Select(x => x.X)]; + float[] ys = [.. validPoints.Select(x => x.Y)]; + + if (xs.Length == 0) + { + Debug.WriteLine("TryToReadBarcodes: no valid points after filtering, skipping"); + continue; + } + + Point minPoint = new(xs.Min(), ys.Min()); + Point maxPoint = new(xs.Max(), ys.Max()); + Point diffs = new(maxPoint.X - minPoint.X, maxPoint.Y - minPoint.Y); + + Debug.WriteLine($"TryToReadBarcodes: minPoint=({minPoint.X:F1},{minPoint.Y:F1}), maxPoint=({maxPoint.X:F1},{maxPoint.Y:F1}), diffs=({diffs.X:F1},{diffs.Y:F1})"); + + if (diffs.Y < 5) + { + Debug.WriteLine($"TryToReadBarcodes: diffs.Y < 5, adjusting diffs.Y from {diffs.Y:F1} to {diffs.X / 10:F1}"); + diffs.Y = diffs.X / 10; + } + + WordBorder wb = new() + { + Word = result.Text, + Width = diffs.X / dpi.DpiScaleX + 12, + Height = diffs.Y / dpi.DpiScaleY + 12, + Left = minPoint.X / (dpi.DpiScaleX) - 6, + Top = minPoint.Y / (dpi.DpiScaleY) - 6, + OwnerGrabFrame = this + }; + Debug.WriteLine($"TryToReadBarcodes: WordBorder Left={wb.Left:F1}, Top={wb.Top:F1}, Width={wb.Width:F1}, Height={wb.Height:F1}"); + wb.SetAsBarcode(); + wordBorders.Add(wb); + _ = RectanglesCanvas.Children.Add(wb); + + UndoRedo.InsertUndoRedoOperation(UndoRedoOperation.AddWordBorder, + new GrabFrameOperationArgs() + { + WordBorder = wb, + WordBorders = wordBorders, + GrabFrameCanvas = RectanglesCanvas + }); + } } private void UndoExecuted(object sender, ExecutedRoutedEventArgs e) From daf47f1efc420e4d0cff411aba5e3e71f482dba6 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Mon, 23 Mar 2026 17:21:12 -0500 Subject: [PATCH 102/109] Clean up the FSG Settings page a bit --- Text-Grab/Pages/FullscreenGrabSettings.xaml | 80 +++++++++++---------- 1 file changed, 42 insertions(+), 38 deletions(-) diff --git a/Text-Grab/Pages/FullscreenGrabSettings.xaml b/Text-Grab/Pages/FullscreenGrabSettings.xaml index 19cbf5f1..ae60ba18 100644 --- a/Text-Grab/Pages/FullscreenGrabSettings.xaml +++ b/Text-Grab/Pages/FullscreenGrabSettings.xaml @@ -4,27 +4,25 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:wpfui="http://schemas.lepo.co/wpfui/2022/xaml" Title="FullscreenGrabSettings" d:DesignHeight="450" d:DesignWidth="800" Loaded="Page_Loaded" mc:Ignorable="d"> - - + + + Text="Defaults" /> - + - Single Line + Single Line (S) - Table + Table (T) - - + - Rectangle 1 - Drag a box + + + Region select (R) + - Window - Hover and click + + + Window select (W) + - Freeform - Draw any shape + + + Draw freeform shape (D) + - Rectangle 2 - Drag, then adjust + + + Adjust after region selection (A) + + Text="Choose how Fullscreen Grab defines the capture area by default. Rectangle 1 is the standard drag-a-box capture, and Rectangle 2 lets you fine-tune the box before grabbing." /> + Content="Send output to Edit Text Window by default" /> + Text="Automatically route capture text into the Edit Text Window (same as pressing E)." /> + Content="Dim screen with translucent overlay while selecting" /> + Text="Controls the transparent overlay shown during Fullscreen Grab selection. Turn off to disable screen dimming." /> + Text="Post-capture insert" /> + Content="Insert captured text into the focused app after closing Fullscreen Grab" /> + Text="Insert delay (seconds):" /> + ValueChanged="InsertDelaySlider_ValueChanged" /> + Style="{StaticResource TextBodyNormal}" /> + Text="How long to wait before simulating paste (Ctrl+V)." /> + Text="Post-capture actions" /> + Text="Configure which actions are available for selection to preform after text is captured (Trim lines, Remove duplicates, etc.)." />