From 97a0345c82a73a28cd4d9f81bd1479129524ff79 Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Mon, 3 Nov 2025 22:07:04 +0100 Subject: [PATCH 1/6] update settings, should now no longer be lost added old xml persister, so if the new json one fails, it tries the old one new files are still saved in the json format, but old ones are converted --- README.md | 59 +- src/.editorconfig | 135 +++- .../Classes/Persister/Persister.cs | 10 +- .../Classes/Persister/PersisterXML.cs | 360 +++++++++ .../Classes/Persister/ProjectPersister.cs | 20 +- .../Classes/Persister/ProjectPersisterXML.cs | 33 + src/LogExpert.Core/Config/ImportResult.cs | 36 + src/LogExpert.Core/Config/LoadResult.cs | 40 + src/LogExpert.Core/Config/Preferences.cs | 2 +- src/LogExpert.Core/Enums/DragOrientations.cs | 8 + .../Enums/DragOrientationsEnum.cs | 8 - .../Interface/IConfigManager.cs | 2 +- src/LogExpert.Core/LogExpert.Core.csproj | 1 + src/LogExpert.Tests/ConfigManagerTest.cs | 658 +++++++++++++++++ .../Controls/DateTimeDragControl.cs | 20 +- .../LogTabWindow/LogTabWindow.designer.cs | 2 +- src/LogExpert.UI/Dialogs/SettingsDialog.cs | 141 ++-- src/LogExpert/Config/ConfigManager.cs | 684 +++++++++++++++--- src/LogExpert/Program.cs | 15 +- 19 files changed, 2004 insertions(+), 230 deletions(-) create mode 100644 src/LogExpert.Core/Classes/Persister/PersisterXML.cs create mode 100644 src/LogExpert.Core/Classes/Persister/ProjectPersisterXML.cs create mode 100644 src/LogExpert.Core/Config/ImportResult.cs create mode 100644 src/LogExpert.Core/Config/LoadResult.cs create mode 100644 src/LogExpert.Core/Enums/DragOrientations.cs delete mode 100644 src/LogExpert.Core/Enums/DragOrientationsEnum.cs create mode 100644 src/LogExpert.Tests/ConfigManagerTest.cs diff --git a/README.md b/README.md index 5ae388a0..618b889c 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # LogExpert [![.NET](https://github.com/LogExperts/LogExpert/actions/workflows/build_dotnet.yml/badge.svg)](https://github.com/LogExperts/LogExpert/actions/workflows/build_dotnet.yml) -This is a clone from (no longer exists) https://logexpert.codeplex.com/ +This is a clone from (no longer exists) + +## Overview -# Overview LogExpert is a Windows tail program (a GUI replacement for the Unix tail command). Summary of (most) features: @@ -23,7 +24,8 @@ Summary of (most) features: * Serilog.Formatting.Compact format support (Experimental) * Portable (all options / settings saved in application startup directory) -# Download +## Download + Follow the [Link](https://github.com/LogExperts/LogExpert/releases/latest) and download the latest package. Just extract it where you want and execute the application or download the Setup and install it Or Install via chocolatey @@ -31,43 +33,52 @@ Or Install via chocolatey ```choco install logexpert``` Requirements -- https://dotnet.microsoft.com/en-us/download/dotnet/8.0 -- .NET 8 (https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-8.0.13-windows-x64-installer or https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-8.0.13-windows-x86-installer) + +* +* .NET 8 ( or ) ## CI + This is a continous integration build. So always the latest and greates changes. It should be stable but no promises. Can be viewed as Beta. [CI Download](https://ci.appveyor.com/project/Zarunbal/logexpert) -# How to Build +## How to Build -- Clone / Fork / Download the source code -- Open the Solution (src/LogExpert.sln) with Visual Studio 2017 (e.g. Community Edition) -- Restore Nuget Packages on Solution -- Build -- The output is under bin/(Debug/Release)/ +* Clone / Fork / Download the source code +* Open the Solution (src/LogExpert.sln) with Visual Studio 2017 (e.g. Community Edition) +* Restore Nuget Packages on Solution +* Build +* The output is under bin/(Debug/Release)/ Nuke.build Requirements -- Chocolatey must be installed -- Optional for Setup Inno Script 5 or 6 -# Pull Request -- Use Development branch as target +* Chocolatey must be installed +* Optional for Setup Inno Script 5 or 6 + +## Pull Request + +* Use Development branch as target + +## FAQ / HELP / Informations / Examples -# FAQ / HELP / Informations / Examples Please checkout the wiki for FAQ / HELP / Informations / Examples -# High DPI -- dont use AutoScaleMode for single GUI controls like Buttons etc. -- dont use AutoScaleDimensions for single GUI controls like Buttons etc. +## High DPI + +* dont use AutoScaleMode for single GUI controls like Buttons etc. + +* dont use AutoScaleDimensions for single GUI controls like Buttons etc. + + + +## Discord Server -https://github.com/LogExperts/LogExpert/wiki + -# Discord Server -https://discord.gg/SjxkuckRe9 +### Credits -## Credits -### Contributors +#### Contributors This project exists thanks to all the people who contribute. diff --git a/src/.editorconfig b/src/.editorconfig index 0792babf..af6a16f3 100644 --- a/src/.editorconfig +++ b/src/.editorconfig @@ -34,23 +34,23 @@ dotnet_sort_system_directives_first = true file_header_template = unset # this. and Me. preferences -dotnet_style_qualification_for_event = false -dotnet_style_qualification_for_field = false -dotnet_style_qualification_for_method = false -dotnet_style_qualification_for_property = false +dotnet_style_qualification_for_event = false:suggestion +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_property = false:suggestion # Language keywords vs BCL types preferences -dotnet_style_predefined_type_for_locals_parameters_members = true:warning -dotnet_style_predefined_type_for_member_access = true:warning +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion # Parentheses preferences -dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:warning +dotnet_style_parentheses_in_arithmetic_binary_operators = never_if_unnecessary:none dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:warning dotnet_style_parentheses_in_other_operators = never_if_unnecessary:warning -dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:warning +dotnet_style_parentheses_in_relational_binary_operators = never_if_unnecessary:none # Modifier preferences -dotnet_style_require_accessibility_modifiers = for_non_interface_members +dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion # Expression-level preferences dotnet_prefer_system_hash_code = true @@ -89,22 +89,22 @@ dotnet_style_allow_statement_immediately_after_block_experimental = false:warnin #### C# Coding Conventions #### # var preferences -csharp_style_var_elsewhere = true:suggestion -csharp_style_var_for_built_in_types = true:warning -csharp_style_var_when_type_is_apparent = true:warning +csharp_style_var_elsewhere = false:suggestion +csharp_style_var_for_built_in_types = false:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion # Expression-bodied members csharp_style_expression_bodied_accessors = true:suggestion -csharp_style_expression_bodied_constructors = false:warning +csharp_style_expression_bodied_constructors = false:suggestion csharp_style_expression_bodied_indexers = true:suggestion csharp_style_expression_bodied_lambdas = true:suggestion csharp_style_expression_bodied_local_functions = true:suggestion -csharp_style_expression_bodied_methods = true:suggestion +csharp_style_expression_bodied_methods = false:suggestion csharp_style_expression_bodied_operators = true:suggestion csharp_style_expression_bodied_properties = true:suggestion # Pattern matching preferences -csharp_style_pattern_matching_over_as_with_null_check = true:warning +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion csharp_style_pattern_matching_over_is_with_cast_check = true:warning csharp_style_prefer_extended_property_pattern = true:warning csharp_style_prefer_not_pattern = true:warning @@ -120,7 +120,7 @@ csharp_prefer_static_anonymous_function = true csharp_prefer_static_local_function = true:warning csharp_style_prefer_readonly_struct = true csharp_style_prefer_readonly_struct_member = true -csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async +csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:suggestion # Code-block preferences csharp_prefer_braces = true:warning @@ -132,11 +132,11 @@ csharp_style_prefer_primary_constructors = true:suggestion csharp_style_prefer_top_level_statements = true:silent # Expression-level preferences -csharp_prefer_simple_default_expression = true:warning +csharp_prefer_simple_default_expression = false:suggestion csharp_style_deconstructed_variable_declaration = true:warning csharp_style_implicit_object_creation_when_type_is_apparent = true:warning -csharp_style_inlined_variable_declaration = true:warning -csharp_style_prefer_local_over_anonymous_function = true:warning +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:suggestion csharp_style_prefer_index_operator = true:warning csharp_style_prefer_null_check_over_type_check = true:warning csharp_style_prefer_range_operator = true:warning @@ -163,7 +163,7 @@ csharp_new_line_before_catch = true csharp_new_line_before_else = true csharp_new_line_before_finally = true csharp_new_line_before_members_in_anonymous_types = true -csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_object_initializers = false csharp_new_line_before_open_brace = all csharp_new_line_between_query_expression_clauses = true @@ -201,11 +201,61 @@ csharp_space_between_square_brackets = false # Wrapping preferences csharp_preserve_single_line_blocks = true -csharp_preserve_single_line_statements = false +csharp_preserve_single_line_statements = true #### Naming styles #### # Naming rules +dotnet_naming_rule.constants_rule.import_to_resharper = as_predefined +dotnet_naming_rule.constants_rule.severity = warning +dotnet_naming_rule.constants_rule.style = all_upper_style +dotnet_naming_rule.constants_rule.symbols = constants_symbols + +dotnet_naming_rule.local_functions_rule.import_to_resharper = as_predefined +dotnet_naming_rule.local_functions_rule.severity = warning +dotnet_naming_rule.local_functions_rule.style = lower_camel_case_style +dotnet_naming_rule.local_functions_rule.symbols = local_functions_symbols + +dotnet_naming_rule.private_constants_rule.import_to_resharper = as_predefined +dotnet_naming_rule.private_constants_rule.severity = warning +dotnet_naming_rule.private_constants_rule.style = all_upper_style +dotnet_naming_rule.private_constants_rule.symbols = private_constants_symbols + +dotnet_naming_rule.private_static_readonly_rule.import_to_resharper = as_predefined +dotnet_naming_rule.private_static_readonly_rule.severity = warning +dotnet_naming_rule.private_static_readonly_rule.style = lower_camel_case_style_1 +dotnet_naming_rule.private_static_readonly_rule.symbols = private_static_readonly_symbols + +dotnet_naming_rule.type_parameters_rule.import_to_resharper = as_predefined +dotnet_naming_rule.type_parameters_rule.severity = warning +dotnet_naming_rule.type_parameters_rule.style = upper_camel_case_style +dotnet_naming_rule.type_parameters_rule.symbols = type_parameters_symbols + +dotnet_naming_style.all_upper_style.capitalization = all_upper +dotnet_naming_style.all_upper_style.word_separator = _ + +dotnet_naming_style.lower_camel_case_style.capitalization = camel_case +dotnet_naming_style.lower_camel_case_style_1.capitalization = camel_case +dotnet_naming_style.lower_camel_case_style_1.required_prefix = _ +dotnet_naming_style.upper_camel_case_style.capitalization = pascal_case + +dotnet_naming_symbols.constants_symbols.applicable_accessibilities = public,internal,protected,protected_internal,private_protected +dotnet_naming_symbols.constants_symbols.applicable_kinds = field +dotnet_naming_symbols.constants_symbols.required_modifiers = const + +dotnet_naming_symbols.local_functions_symbols.applicable_accessibilities = * +dotnet_naming_symbols.local_functions_symbols.applicable_kinds = local_function + +dotnet_naming_symbols.private_constants_symbols.applicable_accessibilities = private +dotnet_naming_symbols.private_constants_symbols.applicable_kinds = field +dotnet_naming_symbols.private_constants_symbols.required_modifiers = const + +dotnet_naming_symbols.private_static_readonly_symbols.applicable_accessibilities = private +dotnet_naming_symbols.private_static_readonly_symbols.applicable_kinds = field +dotnet_naming_symbols.private_static_readonly_symbols.required_modifiers = static,readonly + +dotnet_naming_symbols.type_parameters_symbols.applicable_accessibilities = * +dotnet_naming_symbols.type_parameters_symbols.applicable_kinds = type_parameter dotnet_naming_rule.interface_should_be_begins_with_i.severity = warning dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface @@ -269,6 +319,47 @@ dotnet_diagnostic.CS0649.severity = none dotnet_diagnostic.CS0169.severity = none dotnet_diagnostic.CS1591.severity = none +# ReSharper properties +resharper_braces_for_for = required +resharper_braces_for_foreach = required +resharper_braces_for_ifelse = required +resharper_braces_for_while = required +resharper_csharp_align_multiline_parameter = true +resharper_csharp_insert_final_newline = true +resharper_csharp_max_line_length = 500 +resharper_csharp_use_indent_from_vs = false +resharper_csharp_wrap_lines = false +resharper_indent_nested_fixed_stmt = true +resharper_indent_nested_foreach_stmt = true +resharper_indent_nested_for_stmt = true +resharper_indent_nested_lock_stmt = true +resharper_indent_nested_usings_stmt = true +resharper_indent_nested_while_stmt = true +resharper_indent_preprocessor_if = outdent +resharper_keep_existing_declaration_block_arrangement = false +resharper_keep_existing_embedded_block_arrangement = false +resharper_keep_existing_enum_arrangement = false +resharper_place_accessorholder_attribute_on_same_line = false +resharper_show_autodetect_configure_formatting_tip = false +resharper_space_within_single_line_array_initializer_braces = false +resharper_use_heuristics_for_body_style = true + +# ReSharper inspection severities +resharper_arrange_constructor_or_destructor_body_highlighting = none +resharper_arrange_method_or_operator_body_highlighting = none +resharper_arrange_redundant_parentheses_highlighting = hint +resharper_arrange_this_qualifier_highlighting = hint +resharper_arrange_type_member_modifiers_highlighting = hint +resharper_arrange_type_modifiers_highlighting = hint +resharper_built_in_type_reference_style_for_member_access_highlighting = hint +resharper_built_in_type_reference_style_highlighting = hint +resharper_redundant_base_qualifier_highlighting = warning +resharper_suggest_var_or_type_built_in_types_highlighting = hint +resharper_suggest_var_or_type_elsewhere_highlighting = hint +resharper_suggest_var_or_type_simple_types_highlighting = hint +resharper_use_object_or_collection_initializer_highlighting = hint + + #### Analyzers Rules #### ## Microsoft.CodeAnalysis.CSharp.CodeStyle @@ -295,7 +386,7 @@ dotnet_diagnostic.IDE0005.severity = none dotnet_diagnostic.IDE0007.severity = warning # IDE0008: Use explicit type instead of 'var' -dotnet_diagnostic.IDE0008.severity = warning +dotnet_diagnostic.IDE0008.severity = none # IDE0009: Add this or Me qualification dotnet_diagnostic.IDE0009.severity = warning diff --git a/src/LogExpert.Core/Classes/Persister/Persister.cs b/src/LogExpert.Core/Classes/Persister/Persister.cs index 7d202d35..c6a13d06 100644 --- a/src/LogExpert.Core/Classes/Persister/Persister.cs +++ b/src/LogExpert.Core/Classes/Persister/Persister.cs @@ -285,8 +285,16 @@ private static PersistenceData LoadInternal (string fileName) } catch (Exception ex) when (ex is JsonSerializationException or UnauthorizedAccessException or - IOException) + IOException or + JsonReaderException) { + //Backup try to load xml instead of json + var xmlData = PersisterXML.Load(fileName); + if (xmlData != null) + { + return xmlData; + } + _logger.Error(ex, $"Error loading persistence data from {fileName}"); return null; } diff --git a/src/LogExpert.Core/Classes/Persister/PersisterXML.cs b/src/LogExpert.Core/Classes/Persister/PersisterXML.cs new file mode 100644 index 00000000..5eea6e96 --- /dev/null +++ b/src/LogExpert.Core/Classes/Persister/PersisterXML.cs @@ -0,0 +1,360 @@ +using System.Drawing; +using System.Text; +using System.Text.Json; +using System.Xml; + +using LogExpert.Core.Classes.Filter; +using LogExpert.Core.Entities; + +using NLog; + +namespace LogExpert.Core.Classes.Persister; + +public static class PersisterXML +{ + #region Fields + + private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); + + #endregion + + #region Private Methods + + private static List ReadFilterTabs (XmlElement startNode) + { + List dataList = []; + XmlNode filterTabsNode = startNode.SelectSingleNode("filterTabs"); + if (filterTabsNode != null) + { + XmlNodeList filterTabNodeList = filterTabsNode.ChildNodes; // all "filterTab" nodes + + foreach (XmlNode node in filterTabNodeList) + { + PersistenceData persistenceData = ReadPersistenceDataFromNode(node); + XmlNode filterNode = node.SelectSingleNode("tabFilter"); + + if (filterNode != null) + { + List filterList = ReadFilter(filterNode as XmlElement); + FilterTabData data = new() + { + PersistenceData = persistenceData, + FilterParams = filterList[0] // there's only 1 + }; + + dataList.Add(data); + } + } + } + + return dataList; + } + + private static List ReadFilter (XmlElement startNode) + { + List filterList = []; + XmlNode filtersNode = startNode.SelectSingleNode("filters"); + if (filtersNode != null) + { + XmlNodeList filterNodeList = filtersNode.ChildNodes; // all "filter" nodes + foreach (XmlNode node in filterNodeList) + { + foreach (XmlNode subNode in node.ChildNodes) + { + if (subNode.Name.Equals("params", StringComparison.OrdinalIgnoreCase)) + { + var base64Text = subNode.InnerText; + var data = Convert.FromBase64String(base64Text); + MemoryStream stream = new(data); + + try + { + FilterParams filterParams = JsonSerializer.Deserialize(stream); + filterParams.Init(); + filterList.Add(filterParams); + } + catch (JsonException ex) + { + _logger.Error($"Error while deserializing filter params. Exception Message: {ex.Message}"); + } + } + } + } + } + + return filterList; + } + + private static PersistenceData LoadInternal (string fileName) + { + XmlDocument xmlDoc = new(); + xmlDoc.Load(fileName); + XmlNode fileNode = xmlDoc.SelectSingleNode("logexpert/file"); + PersistenceData persistenceData = new(); + if (fileNode != null) + { + persistenceData = ReadPersistenceDataFromNode(fileNode); + } + + return persistenceData; + } + + private static PersistenceData ReadPersistenceDataFromNode (XmlNode node) + { + PersistenceData persistenceData = new(); + var fileElement = node as XmlElement; + persistenceData.BookmarkList = ReadBookmarks(fileElement); + persistenceData.RowHeightList = ReadRowHeightList(fileElement); + ReadOptions(fileElement, persistenceData); + persistenceData.FileName = fileElement.GetAttribute("fileName"); + var sLineCount = fileElement.GetAttribute("lineCount"); + if (sLineCount != null && sLineCount.Length > 0) + { + persistenceData.LineCount = int.Parse(sLineCount); + } + + persistenceData.FilterParamsList = ReadFilter(fileElement); + persistenceData.FilterTabDataList = ReadFilterTabs(fileElement); + persistenceData.Encoding = ReadEncoding(fileElement); + return persistenceData; + } + + private static Encoding ReadEncoding (XmlElement fileElement) + { + XmlNode encodingNode = fileElement.SelectSingleNode("encoding"); + if (encodingNode != null) + { + XmlAttribute encAttr = encodingNode.Attributes["name"]; + try + { + return encAttr == null ? null : Encoding.GetEncoding(encAttr.Value); + } + catch (ArgumentException e) + { + _logger.Error(e); + return Encoding.Default; + } + catch (NotSupportedException e) + { + _logger.Error(e); + return Encoding.Default; + } + } + + return null; + } + + private static SortedList ReadBookmarks (XmlElement startNode) + { + SortedList bookmarkList = []; + XmlNode boomarksNode = startNode.SelectSingleNode("bookmarks"); + if (boomarksNode != null) + { + XmlNodeList bookmarkNodeList = boomarksNode.ChildNodes; // all "bookmark" nodes + foreach (XmlNode node in bookmarkNodeList) + { + string text = null; + string posX = null; + string posY = null; + string line = null; + + foreach (XmlAttribute attr in node.Attributes) + { + if (attr.Name.Equals("line", StringComparison.OrdinalIgnoreCase)) + { + line = attr.InnerText; + } + } + + foreach (XmlNode subNode in node.ChildNodes) + { + if (subNode.Name.Equals("text", StringComparison.OrdinalIgnoreCase)) + { + text = subNode.InnerText; + } + else if (subNode.Name.Equals("posX", StringComparison.OrdinalIgnoreCase)) + { + posX = subNode.InnerText; + } + else if (subNode.Name.Equals("posY", StringComparison.OrdinalIgnoreCase)) + { + posY = subNode.InnerText; + } + } + + if (line == null || posX == null || posY == null) + { + _logger.Error($"Invalid XML format for bookmark: {node.InnerText}"); + continue; + } + + var lineNum = int.Parse(line); + + Entities.Bookmark bookmark = new(lineNum) + { + OverlayOffset = new Size(int.Parse(posX), int.Parse(posY)) + }; + + if (text != null) + { + bookmark.Text = text; + } + + bookmarkList.Add(lineNum, bookmark); + } + } + + return bookmarkList; + } + + private static SortedList ReadRowHeightList (XmlElement startNode) + { + SortedList rowHeightList = []; + XmlNode rowHeightsNode = startNode.SelectSingleNode("rowheights"); + if (rowHeightsNode != null) + { + XmlNodeList rowHeightNodeList = rowHeightsNode.ChildNodes; // all "rowheight" nodes + foreach (XmlNode node in rowHeightNodeList) + { + string height = null; + string line = null; + foreach (XmlAttribute attr in node.Attributes) + { + if (attr.Name.Equals("line", StringComparison.OrdinalIgnoreCase)) + { + line = attr.InnerText; + } + else if (attr.Name.Equals("height", StringComparison.OrdinalIgnoreCase)) + { + height = attr.InnerText; + } + } + + var lineNum = int.Parse(line); + var heightValue = int.Parse(height); + rowHeightList.Add(lineNum, new RowHeightEntry(lineNum, heightValue)); + } + } + + return rowHeightList; + } + + private static void ReadOptions (XmlElement startNode, PersistenceData persistenceData) + { + XmlNode optionsNode = startNode.SelectSingleNode("options"); + var value = GetOptionsAttribute(optionsNode, "multifile", "enabled"); + persistenceData.MultiFile = value != null && value.Equals("1", StringComparison.OrdinalIgnoreCase); + persistenceData.MultiFilePattern = GetOptionsAttribute(optionsNode, "multifile", "pattern"); + value = GetOptionsAttribute(optionsNode, "multifile", "maxDays"); + try + { + persistenceData.MultiFileMaxDays = value != null ? short.Parse(value) : 0; + } + catch (Exception) + { + persistenceData.MultiFileMaxDays = 0; + } + + XmlNode multiFileNode = optionsNode.SelectSingleNode("multifile"); + if (multiFileNode != null) + { + XmlNodeList multiFileNodeList = multiFileNode.ChildNodes; // all "fileEntry" nodes + foreach (XmlNode node in multiFileNodeList) + { + string fileName = null; + foreach (XmlAttribute attr in node.Attributes) + { + if (attr.Name.Equals("fileName", StringComparison.OrdinalIgnoreCase)) + { + fileName = attr.InnerText; + } + } + + persistenceData.MultiFileNames.Add(fileName); + } + } + + value = GetOptionsAttribute(optionsNode, "currentline", "line"); + if (value != null) + { + persistenceData.CurrentLine = int.Parse(value); + } + + value = GetOptionsAttribute(optionsNode, "firstDisplayedLine", "line"); + if (value != null) + { + persistenceData.FirstDisplayedLine = int.Parse(value); + } + + value = GetOptionsAttribute(optionsNode, "filter", "visible"); + persistenceData.FilterVisible = value != null && value.Equals("1", StringComparison.OrdinalIgnoreCase); + value = GetOptionsAttribute(optionsNode, "filter", "advanced"); + persistenceData.FilterAdvanced = value != null && value.Equals("1", StringComparison.OrdinalIgnoreCase); + value = GetOptionsAttribute(optionsNode, "filter", "position"); + if (value != null) + { + persistenceData.FilterPosition = int.Parse(value); + } + + value = GetOptionsAttribute(optionsNode, "bookmarklist", "visible"); + persistenceData.BookmarkListVisible = value != null && value.Equals("1", StringComparison.OrdinalIgnoreCase); + value = GetOptionsAttribute(optionsNode, "bookmarklist", "position"); + if (value != null) + { + persistenceData.BookmarkListPosition = int.Parse(value); + } + + value = GetOptionsAttribute(optionsNode, "followTail", "enabled"); + persistenceData.FollowTail = value != null && value.Equals("1", StringComparison.OrdinalIgnoreCase); + + value = GetOptionsAttribute(optionsNode, "bookmarkCommentColumn", "visible"); + persistenceData.ShowBookmarkCommentColumn = value != null && value.Equals("1", StringComparison.OrdinalIgnoreCase); + + value = GetOptionsAttribute(optionsNode, "filterSaveList", "visible"); + persistenceData.FilterSaveListVisible = value != null && value.Equals("1", StringComparison.OrdinalIgnoreCase); + + XmlNode tabNode = startNode.SelectSingleNode("tab"); + if (tabNode != null) + { + persistenceData.TabName = (tabNode as XmlElement).GetAttribute("name"); + } + + XmlNode columnizerNode = startNode.SelectSingleNode("columnizer"); + if (columnizerNode != null) + { + persistenceData.ColumnizerName = (columnizerNode as XmlElement).GetAttribute("name"); + } + + XmlNode highlightGroupNode = startNode.SelectSingleNode("highlightGroup"); + if (highlightGroupNode != null) + { + persistenceData.HighlightGroupName = (highlightGroupNode as XmlElement).GetAttribute("name"); + } + } + + private static string GetOptionsAttribute (XmlNode optionsNode, string elementName, string attrName) + { + XmlNode node = optionsNode.SelectSingleNode(elementName); + if (node == null) + { + return null; + } + + if (node is XmlElement) + { + var value = (node as XmlElement).GetAttribute(attrName); + return value; + } + else + { + return null; + } + } + + public static PersistenceData Load (string fileName) + { + return LoadInternal(fileName); + } + + #endregion +} \ No newline at end of file diff --git a/src/LogExpert.Core/Classes/Persister/ProjectPersister.cs b/src/LogExpert.Core/Classes/Persister/ProjectPersister.cs index 726f1566..5e9df3f7 100644 --- a/src/LogExpert.Core/Classes/Persister/ProjectPersister.cs +++ b/src/LogExpert.Core/Classes/Persister/ProjectPersister.cs @@ -12,6 +12,11 @@ public static class ProjectPersister #region Public methods + /// + /// Loads the project session data from a specified file. + /// + /// + /// public static ProjectData LoadProjectData (string projectFileName) { try @@ -25,13 +30,26 @@ public static ProjectData LoadProjectData (string projectFileName) return JsonConvert.DeserializeObject(json, settings); } catch (Exception ex) when (ex is UnauthorizedAccessException or - IOException) + IOException or + JsonSerializationException) { + //Backup try to load xml instead of json + var xmlData = ProjectPersisterXML.LoadProjectData(projectFileName); + if (xmlData != null) + { + return xmlData; + } + _logger.Error(ex, $"Error loading persistence data from {projectFileName}"); return new ProjectData(); } } + /// + /// Saves the project session data to a specified file. + /// + /// + /// public static void SaveProjectData (string projectFileName, ProjectData projectData) { var settings = new JsonSerializerSettings diff --git a/src/LogExpert.Core/Classes/Persister/ProjectPersisterXML.cs b/src/LogExpert.Core/Classes/Persister/ProjectPersisterXML.cs new file mode 100644 index 00000000..2803a883 --- /dev/null +++ b/src/LogExpert.Core/Classes/Persister/ProjectPersisterXML.cs @@ -0,0 +1,33 @@ +using System.Xml; + +namespace LogExpert.Core.Classes.Persister; + +public static class ProjectPersisterXML +{ + #region Public methods + + public static ProjectData LoadProjectData (string projectFileName) + { + var projectData = new ProjectData(); + var xmlDoc = new XmlDocument(); + xmlDoc.Load(projectFileName); + var fileList = xmlDoc.GetElementsByTagName("member"); + + foreach (XmlNode fileNode in fileList) + { + var fileElement = fileNode as XmlElement; + var fileName = fileElement.GetAttribute("fileName"); + projectData.FileNames.Add(fileName); + } + + var layoutElements = xmlDoc.GetElementsByTagName("layout"); + if (layoutElements.Count > 0) + { + projectData.TabLayoutXml = layoutElements[0].InnerXml; + } + + return projectData; + } + + #endregion +} \ No newline at end of file diff --git a/src/LogExpert.Core/Config/ImportResult.cs b/src/LogExpert.Core/Config/ImportResult.cs new file mode 100644 index 00000000..5474630c --- /dev/null +++ b/src/LogExpert.Core/Config/ImportResult.cs @@ -0,0 +1,36 @@ +namespace LogExpert.Core.Config; + +/// +/// Result of a settings import operation +/// +public class ImportResult +{ + public bool Success { get; set; } + + public string ErrorMessage { get; set; } + + public string ErrorTitle { get; set; } + + public bool RequiresUserConfirmation { get; set; } + + public string ConfirmationMessage { get; set; } + + public string ConfirmationTitle { get; set; } + + public static ImportResult Successful () => new() { Success = true }; + + public static ImportResult Failed (string title, string message) => new() + { + Success = false, + ErrorTitle = title, + ErrorMessage = message + }; + + public static ImportResult RequiresConfirmation (string title, string message) => new() + { + Success = false, + RequiresUserConfirmation = true, + ConfirmationTitle = title, + ConfirmationMessage = message + }; +} diff --git a/src/LogExpert.Core/Config/LoadResult.cs b/src/LogExpert.Core/Config/LoadResult.cs new file mode 100644 index 00000000..8b5b0843 --- /dev/null +++ b/src/LogExpert.Core/Config/LoadResult.cs @@ -0,0 +1,40 @@ +using LogExpert.Core.Entities; + +namespace LogExpert.Core.Config; + +/// +/// Result of a settings load operation +/// +public class LoadResult +{ + public Settings Settings { get; set; } + public bool LoadedFromBackup { get; set; } + public string RecoveryMessage { get; set; } + public string RecoveryTitle { get; set; } + public bool CriticalFailure { get; set; } + public string CriticalMessage { get; set; } + public string CriticalTitle { get; set; } + public bool RequiresUserChoice { get; set; } + + public static LoadResult Success(Settings settings) => new() + { + Settings = settings + }; + + public static LoadResult FromBackup(Settings settings, string message, string title) => new() + { + Settings = settings, + LoadedFromBackup = true, + RecoveryMessage = message, + RecoveryTitle = title + }; + + public static LoadResult Critical(Settings settings, string title, string message) => new() + { + Settings = settings, + CriticalFailure = true, + CriticalTitle = title, + CriticalMessage = message, + RequiresUserChoice = true + }; +} diff --git a/src/LogExpert.Core/Config/Preferences.cs b/src/LogExpert.Core/Config/Preferences.cs index 779e6fb8..fece418d 100644 --- a/src/LogExpert.Core/Config/Preferences.cs +++ b/src/LogExpert.Core/Config/Preferences.cs @@ -26,7 +26,7 @@ public class Preferences public List ToolEntries { get; set; } = []; - public DragOrientationsEnum TimestampControlDragOrientation { get; set; } = DragOrientationsEnum.Horizontal; + public DragOrientations TimestampControlDragOrientation { get; set; } = DragOrientations.Horizontal; public bool TimestampControl { get; set; } diff --git a/src/LogExpert.Core/Enums/DragOrientations.cs b/src/LogExpert.Core/Enums/DragOrientations.cs new file mode 100644 index 00000000..14a00a37 --- /dev/null +++ b/src/LogExpert.Core/Enums/DragOrientations.cs @@ -0,0 +1,8 @@ +namespace LogExpert.Core.Enums; + +public enum DragOrientations +{ + Horizontal, + Vertical, + InvertedVertical +} diff --git a/src/LogExpert.Core/Enums/DragOrientationsEnum.cs b/src/LogExpert.Core/Enums/DragOrientationsEnum.cs deleted file mode 100644 index fb6465c4..00000000 --- a/src/LogExpert.Core/Enums/DragOrientationsEnum.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace LogExpert.Core.Enums; - -public enum DragOrientationsEnum -{ - Horizontal, - Vertical, - InvertedVertical -} diff --git a/src/LogExpert.Core/Interface/IConfigManager.cs b/src/LogExpert.Core/Interface/IConfigManager.cs index 93a079dd..9abfcdf0 100644 --- a/src/LogExpert.Core/Interface/IConfigManager.cs +++ b/src/LogExpert.Core/Interface/IConfigManager.cs @@ -20,7 +20,7 @@ public interface IConfigManager void Export (FileInfo fileInfo); - void Import (FileInfo fileInfo, ExportImportFlags importFlags); + ImportResult Import (FileInfo fileInfo, ExportImportFlags importFlags); void ImportHighlightSettings (FileInfo fileInfo, ExportImportFlags importFlags); diff --git a/src/LogExpert.Core/LogExpert.Core.csproj b/src/LogExpert.Core/LogExpert.Core.csproj index 094d3861..8b8e1e8f 100644 --- a/src/LogExpert.Core/LogExpert.Core.csproj +++ b/src/LogExpert.Core/LogExpert.Core.csproj @@ -3,6 +3,7 @@ net8.0 true + LogExpert.Core diff --git a/src/LogExpert.Tests/ConfigManagerTest.cs b/src/LogExpert.Tests/ConfigManagerTest.cs new file mode 100644 index 00000000..8ce0a536 --- /dev/null +++ b/src/LogExpert.Tests/ConfigManagerTest.cs @@ -0,0 +1,658 @@ +using System.Reflection; + +using LogExpert.Config; +using LogExpert.Core.Classes.Filter; +using LogExpert.Core.Config; +using LogExpert.Core.Entities; + +using Newtonsoft.Json; + +using NUnit.Framework; + +namespace LogExpert.Tests; + +/// +/// Unit tests for ConfigManager settings loss prevention fixes. +/// Tests all 4 Priority 1 implementations: Import Validation, Atomic Write, Deserialization Recovery, Settings Validation. +/// +[TestFixture] +public class ConfigManagerTest +{ + private string _testDir; + private FileInfo _testSettingsFile; + + [SetUp] + public void SetUp () + { + // Create isolated test directory for each test + _testDir = Path.Combine(Path.GetTempPath(), "LogExpert_Test_" + Guid.NewGuid().ToString("N")); + _ = Directory.CreateDirectory(_testDir); + _testSettingsFile = new FileInfo(Path.Combine(_testDir, "settings.json")); + } + + [TearDown] + public void TearDown () + { + // Cleanup test directory + if (Directory.Exists(_testDir)) + { + try + { + Directory.Delete(_testDir, recursive: true); + } + catch + { + // Ignore cleanup errors + } + } + } + + #region Helper Methods + + /// + /// Invokes a private static method using reflection. + /// + private T InvokePrivateStaticMethod (string methodName, params object[] parameters) + { + MethodInfo? method = typeof(ConfigManager).GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Static); + + if (method == null) + { + throw new Exception($"Static method {methodName} not found"); + } + + return (T)method.Invoke(null, parameters); + } + + /// + /// Invokes a private instance method using reflection. + /// + private T InvokePrivateInstanceMethod (string methodName, params object[] parameters) + { + MethodInfo? method = typeof(ConfigManager).GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance); + + if (method == null) + { + throw new Exception($"Instance method {methodName} not found"); + } + + return (T)method.Invoke(ConfigManager.Instance, parameters); + } + + /// + /// Invokes a private instance method with no return value using reflection. + /// + private void InvokePrivateInstanceMethod (string methodName, params object[] parameters) + { + MethodInfo? method = typeof(ConfigManager).GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance); + + if (method == null) + { + throw new Exception($"Instance method {methodName} not found"); + } + + method.Invoke(ConfigManager.Instance, parameters); + } + + /// + /// Creates a basic test Settings object with valid defaults. + /// + private Settings CreateTestSettings () + { + var settings = new Settings(); + settings.Preferences = new Preferences(); + return settings; + } + + /// + /// Creates a populated Settings object with sample data. + /// + private Settings CreatePopulatedSettings () + { + Settings settings = CreateTestSettings(); + settings.FilterList.Add(new FilterParams { SearchText = "ERROR" }); + settings.FilterList.Add(new FilterParams { SearchText = "WARNING" }); + settings.SearchHistoryList.Add("exception"); + settings.SearchHistoryList.Add("error"); + settings.Preferences.HighlightGroupList.Add(new HighlightGroup { GroupName = "Errors" }); + return settings; + } + + #endregion + + #region Phase 1: Import Validation Tests + + [Test] + [Category("ImportValidation")] + [Description("SettingsAreEmptyOrDefault should return true for null settings")] + public void SettingsAreEmptyOrDefault_NullSettings_ReturnsTrue () + { + // Arrange + Settings settings = null; + + // Act + bool result = InvokePrivateStaticMethod("SettingsAreEmptyOrDefault", settings); + + // Assert + Assert.That(result, Is.True, "Null settings should be detected as empty/default"); + } + + [Test] + [Category("ImportValidation")] + [Description("SettingsAreEmptyOrDefault should return true for empty settings")] + public void SettingsAreEmptyOrDefault_EmptySettings_ReturnsTrue () + { + // Arrange + Settings settings = CreateTestSettings(); + + // Act + bool result = InvokePrivateStaticMethod("SettingsAreEmptyOrDefault", settings); + + // Assert + Assert.That(result, Is.True, "Empty settings should be detected as empty/default"); + } + + [Test] + [Category("ImportValidation")] + [Description("SettingsAreEmptyOrDefault should return false for settings with filters")] + public void SettingsAreEmptyOrDefault_SettingsWithFilters_ReturnsFalse () + { + // Arrange + Settings settings = CreateTestSettings(); + settings.FilterList.Add(new FilterParams { SearchText = "TEST_FILTER" }); + + // Act + bool result = InvokePrivateStaticMethod("SettingsAreEmptyOrDefault", settings); + + // Assert + Assert.That(result, Is.False, "Settings with filters should not be empty/default"); + } + + [Test] + [Category("ImportValidation")] + [Description("SettingsAreEmptyOrDefault should return false for settings with search history")] + public void SettingsAreEmptyOrDefault_SettingsWithHistory_ReturnsFalse () + { + // Arrange + Settings settings = CreateTestSettings(); + settings.SearchHistoryList.Add("test search"); + + // Act + bool result = InvokePrivateStaticMethod("SettingsAreEmptyOrDefault", settings); + + // Assert + Assert.That(result, Is.False, "Settings with search history should not be empty/default"); + } + + [Test] + [Category("ImportValidation")] + [Description("SettingsAreEmptyOrDefault should return false for settings with highlights")] + public void SettingsAreEmptyOrDefault_SettingsWithHighlights_ReturnsFalse () + { + // Arrange + Settings settings = CreateTestSettings(); + settings.Preferences.HighlightGroupList.Add(new HighlightGroup { GroupName = "Test" }); + + // Act + bool result = InvokePrivateStaticMethod("SettingsAreEmptyOrDefault", settings); + + // Assert + Assert.That(result, Is.False, "Settings with highlights should not be empty/default"); + } + + [Test] + [Category("ImportValidation")] + [Description("ValidateSettings should handle null settings gracefully")] + public void ValidateSettings_NullSettings_ReturnsFalse () + { + // Arrange + Settings settings = null; + + // Act + bool result = InvokePrivateInstanceMethod("ValidateSettings", settings); + + // Assert + Assert.That(result, Is.False, "Null settings should fail validation"); + } + + [Test] + [Category("ImportValidation")] + [Description("ValidateSettings should return true for valid populated settings")] + public void ValidateSettings_ValidPopulatedSettings_ReturnsTrue () + { + // Arrange + Settings settings = CreatePopulatedSettings(); + + // Act + bool result = InvokePrivateInstanceMethod("ValidateSettings", settings); + + // Assert + Assert.That(result, Is.True, "Valid populated settings should pass validation"); + } + + [Test] + [Category("ImportValidation")] + [Description("ValidateSettings should return true for valid empty settings")] + public void ValidateSettings_ValidEmptySettings_ReturnsTrue () + { + // Arrange + Settings settings = CreateTestSettings(); + + // Act + bool result = InvokePrivateInstanceMethod("ValidateSettings", settings); + + // Assert + Assert.That(result, Is.True, "Valid empty settings should pass validation (may log warning)"); + } + + #endregion + + #region Phase 2: Atomic Write Tests + + [Test] + [Category("AtomicWrite")] + [Description("SaveAsJSON should create main file and cleanup temp file")] + public void SaveAsJSON_CreatesMainFileAndCleanupsTempFile () + { + // Arrange + Settings settings = CreatePopulatedSettings(); + settings.AlwaysOnTop = true; + + // Act + InvokePrivateInstanceMethod("SaveAsJSON", _testSettingsFile, settings); + + // Assert + string tempFile = _testSettingsFile.FullName + ".tmp"; + Assert.That(File.Exists(tempFile), Is.False, "Temp file should be cleaned up"); + Assert.That(_testSettingsFile.Exists, Is.True, "Main file should exist"); + + // Verify content + string json = File.ReadAllText(_testSettingsFile.FullName); + Assert.That(json, Does.Contain("AlwaysOnTop")); + Assert.That(json, Does.Contain("TEST_FILTER").Or.Contain("ERROR")); + } + + [Test] + [Category("AtomicWrite")] + [Description("SaveAsJSON should create backup file on second save")] + public void SaveAsJSON_CreatesBackupFile () + { + // Arrange + Settings settings1 = CreateTestSettings(); + settings1.AlwaysOnTop = true; + InvokePrivateInstanceMethod("SaveAsJSON", _testSettingsFile, settings1); + + Settings settings2 = CreateTestSettings(); + settings2.AlwaysOnTop = false; + + // Act + InvokePrivateInstanceMethod("SaveAsJSON", _testSettingsFile, settings2); + + // Assert + string backupFile = _testSettingsFile.FullName + ".bak"; + Assert.That(File.Exists(backupFile), Is.True, "Backup file should exist"); + + string backupContent = File.ReadAllText(backupFile); + Settings? backupSettings = JsonConvert.DeserializeObject(backupContent); + Assert.That(backupSettings.AlwaysOnTop, Is.True, "Backup should contain previous settings"); + + string mainContent = File.ReadAllText(_testSettingsFile.FullName); + Settings? mainSettings = JsonConvert.DeserializeObject(mainContent); + Assert.That(mainSettings.AlwaysOnTop, Is.False, "Main file should contain new settings"); + } + + [Test] + [Category("AtomicWrite")] + [Description("SaveAsJSON should not backup empty/zero-byte files")] + public void SaveAsJSON_DoesNotBackupEmptyFile () + { + // Arrange + File.WriteAllText(_testSettingsFile.FullName, ""); // Create empty file + Settings settings = CreateTestSettings(); + + // Act + InvokePrivateInstanceMethod("SaveAsJSON", _testSettingsFile, settings); + + // Assert + string backupFile = _testSettingsFile.FullName + ".bak"; + Assert.That(File.Exists(backupFile), Is.False, "Empty file should not be backed up"); + Assert.That(_testSettingsFile.Exists, Is.True, "Main file should exist"); + Assert.That(new FileInfo(_testSettingsFile.FullName).Length, Is.GreaterThan(0), "Main file should not be empty"); + } + + [Test] + [Category("AtomicWrite")] + [Description("SaveAsJSON should save complete valid JSON that can be deserialized")] + public void SaveAsJSON_SavesCompleteValidJSON () + { + // Arrange + Settings settings = CreateTestSettings(); + settings.FilterList.Add(new FilterParams { SearchText = "TEST_FILTER_123" }); + settings.SearchHistoryList.Add("TEST_SEARCH_456"); + settings.AlwaysOnTop = true; + + // Act + InvokePrivateInstanceMethod("SaveAsJSON", _testSettingsFile, settings); + + // Assert + Assert.That(_testSettingsFile.Exists, Is.True); + string json = File.ReadAllText(_testSettingsFile.FullName); + + // Verify content present + Assert.That(json, Does.Contain("TEST_FILTER_123")); + Assert.That(json, Does.Contain("TEST_SEARCH_456")); + + // Verify can deserialize + Settings loaded = null; + Assert.DoesNotThrow(() => loaded = JsonConvert.DeserializeObject(json), "Saved JSON should be valid and deserializable"); + + Assert.That(loaded, Is.Not.Null); + Assert.That(loaded.FilterList.Count, Is.EqualTo(1)); + Assert.That(loaded.FilterList[0].SearchText, Is.EqualTo("TEST_FILTER_123")); + Assert.That(loaded.SearchHistoryList.Count, Is.EqualTo(1)); + Assert.That(loaded.AlwaysOnTop, Is.True); + } + + [Test] + [Category("AtomicWrite")] + [Description("SaveAsJSON validation should prevent saving null settings")] + public void SaveAsJSON_ValidationFailure_PreventsNullSettingsSave () + { + // Arrange + Settings settings = null; + + // Act & Assert + _ = Assert.Throws(() => InvokePrivateInstanceMethod("SaveAsJSON", _testSettingsFile, settings), "Saving null settings should throw exception"); + + Assert.That(_testSettingsFile.Exists, Is.False, "File should not be created if validation fails"); + } + + [Test] + [Category("AtomicWrite")] + [Description("SaveAsJSON should maintain file integrity across multiple saves")] + public void SaveAsJSON_MultipleSaves_MaintainsIntegrity () + { + // Arrange & Act - Multiple saves + for (int i = 0; i < 5; i++) + { + Settings settings = CreateTestSettings(); + settings.FilterList.Add(new FilterParams { SearchText = $"FILTER_{i}" }); + InvokePrivateInstanceMethod("SaveAsJSON", _testSettingsFile, settings); + } + + // Assert + Assert.That(_testSettingsFile.Exists, Is.True); + string json = File.ReadAllText(_testSettingsFile.FullName); + Settings? loaded = JsonConvert.DeserializeObject(json); + + Assert.That(loaded, Is.Not.Null); + Assert.That(loaded.FilterList.Count, Is.EqualTo(1), "Last save should have 1 filter"); + Assert.That(loaded.FilterList[0].SearchText, Is.EqualTo("FILTER_4"), "Should have last filter"); + + // Verify backup has previous version + string backupFile = _testSettingsFile.FullName + ".bak"; + if (File.Exists(backupFile)) + { + string backupJson = File.ReadAllText(backupFile); + Settings? backupLoaded = JsonConvert.DeserializeObject(backupJson); + Assert.That(backupLoaded.FilterList[0].SearchText, Is.EqualTo("FILTER_3"), "Backup should have previous version"); + } + } + + #endregion + + #region Phase 3: Deserialization Recovery Tests + + [Test] + [Category("DeserializationRecovery")] + [Description("LoadOrCreateNew should load valid settings file successfully")] + public void LoadOrCreateNew_ValidFile_LoadsSuccessfully () + { + // Arrange + Settings settings = CreatePopulatedSettings(); + settings.FilterList.Clear(); + settings.FilterList.Add(new FilterParams { SearchText = "VALID_FILTER_TEST" }); + string json = JsonConvert.SerializeObject(settings); + File.WriteAllText(_testSettingsFile.FullName, json); + + // Act + LoadResult loadResult = InvokePrivateInstanceMethod("LoadOrCreateNew", _testSettingsFile); + + // Assert + Assert.That(loadResult, Is.Not.Null); + Assert.That(loadResult.Settings, Is.Not.Null); + Assert.That(loadResult.LoadedFromBackup, Is.False, "Should not load from backup for valid file"); + Assert.That(loadResult.CriticalFailure, Is.False, "Should not have critical failure"); + Assert.That(loadResult.Settings.FilterList.Count, Is.EqualTo(1)); + Assert.That(loadResult.Settings.FilterList[0].SearchText, Is.EqualTo("VALID_FILTER_TEST")); + } + + [Test] + [Category("DeserializationRecovery")] + [Description("LoadOrCreateNew should handle missing file by creating new settings")] + public void LoadOrCreateNew_MissingFile_CreatesNewSettings () + { + // Arrange - file doesn't exist + + // Act + LoadResult loadResult = InvokePrivateInstanceMethod("LoadOrCreateNew", (FileInfo)null); + + // Assert + Assert.That(loadResult, Is.Not.Null); + Assert.That(loadResult.Settings, Is.Not.Null, "Should create new settings when file is null"); + Assert.That(loadResult.Settings.Preferences, Is.Not.Null, "Settings should have preferences initialized"); + Assert.That(loadResult.LoadedFromBackup, Is.False); + Assert.That(loadResult.CriticalFailure, Is.False); + } + + [Test] + [Category("DeserializationRecovery")] + [Description("LoadOrCreateNew should handle empty file gracefully")] + public void LoadOrCreateNew_EmptyFile_HandlesGracefully () + { + // Arrange + File.WriteAllText(_testSettingsFile.FullName, ""); + + // Act + LoadResult loadResult = InvokePrivateInstanceMethod("LoadOrCreateNew", _testSettingsFile); + + // Assert + Assert.That(loadResult, Is.Not.Null); + Assert.That(loadResult.Settings, Is.Not.Null, "Should return valid settings object, not null"); + Assert.That(loadResult.Settings.Preferences, Is.Not.Null, "Settings should have preferences"); + // Empty file triggers recovery, may create new settings + } + + [Test] + [Category("DeserializationRecovery")] + [Description("LoadOrCreateNew should handle null JSON deserialization result")] + public void LoadOrCreateNew_NullDeserializationResult_HandlesGracefully () + { + // Arrange + File.WriteAllText(_testSettingsFile.FullName, "null"); + + // Act + LoadResult loadResult = InvokePrivateInstanceMethod("LoadOrCreateNew", _testSettingsFile); + + // Assert + Assert.That(loadResult, Is.Not.Null); + Assert.That(loadResult.Settings, Is.Not.Null, "Should not return null settings"); + Assert.That(loadResult.Settings.Preferences, Is.Not.Null); + } + + [Test] + [Category("DeserializationRecovery")] + [Description("LoadOrCreateNew should recover from backup when main file is corrupt")] + public void LoadOrCreateNew_CorruptFileWithBackup_RecoversFromBackup () + { + // Arrange - Create good backup + Settings goodSettings = CreateTestSettings(); + goodSettings.FilterList.Add(new FilterParams { SearchText = "BACKUP_FILTER_TEST" }); + string backupFile = _testSettingsFile.FullName + ".bak"; + File.WriteAllText(backupFile, JsonConvert.SerializeObject(goodSettings)); + + // Create corrupt main file + File.WriteAllText(_testSettingsFile.FullName, "{\"corrupt\": json}"); + + // Act + LoadResult loadResult = InvokePrivateInstanceMethod("LoadOrCreateNew", _testSettingsFile); + + // Assert + Assert.That(loadResult, Is.Not.Null); + Assert.That(loadResult.Settings, Is.Not.Null); + Assert.That(loadResult.LoadedFromBackup, Is.True, "Should indicate loaded from backup"); + Assert.That(loadResult.RecoveryMessage, Is.Not.Null.And.Not.Empty, "Should provide recovery message"); + Assert.That(loadResult.RecoveryTitle, Is.Not.Null.And.Not.Empty, "Should provide recovery title"); + + // Verify backup recovery worked - should have BACKUP_FILTER_TEST + Assert.That(loadResult.Settings.FilterList.Count, Is.EqualTo(1)); + Assert.That(loadResult.Settings.FilterList[0].SearchText, Is.EqualTo("BACKUP_FILTER_TEST")); + + // Verify corrupt file preserved + string corruptFile = _testSettingsFile.FullName + ".corrupt"; + Assert.That(File.Exists(corruptFile), Is.True, "Corrupt file should be preserved"); + } + + [Test] + [Category("DeserializationRecovery")] + [Description("LoadOrCreateNew should handle corrupt JSON with invalid syntax")] + public void LoadOrCreateNew_InvalidJSON_HandlesGracefully () + { + // Arrange + File.WriteAllText(_testSettingsFile.FullName, "{invalid json syntax"); + + // Act + LoadResult loadResult = InvokePrivateInstanceMethod("LoadOrCreateNew", _testSettingsFile); + + // Assert + Assert.That(loadResult, Is.Not.Null); + Assert.That(loadResult.Settings, Is.Not.Null, "Should return valid settings object"); + // Without backup, will return CriticalFailure or create new settings + Assert.That(loadResult.Settings.Preferences, Is.Not.Null); + } + + #endregion + + #region Phase 4: Integration Tests + + [Test] + [Category("Integration")] + [Description("End-to-end save and load should preserve all settings")] + public void SaveAndLoad_PreservesAllSettings () + { + // Arrange + Settings originalSettings = CreateTestSettings(); + originalSettings.AlwaysOnTop = true; + originalSettings.FilterList.Add(new FilterParams { SearchText = "FILTER1" }); + originalSettings.FilterList.Add(new FilterParams { SearchText = "FILTER2" }); + originalSettings.SearchHistoryList.Add("SEARCH1"); + originalSettings.SearchHistoryList.Add("SEARCH2"); + originalSettings.Preferences.HighlightGroupList.Add(new HighlightGroup { GroupName = "GROUP1" }); + + // Act - Save + InvokePrivateInstanceMethod("SaveAsJSON", _testSettingsFile, originalSettings); + + // Act - Load + LoadResult loadResult = InvokePrivateInstanceMethod("LoadOrCreateNew", _testSettingsFile); + + // Assert + Assert.That(loadResult, Is.Not.Null); + Assert.That(loadResult.Settings, Is.Not.Null); + Assert.That(loadResult.Settings.AlwaysOnTop, Is.EqualTo(originalSettings.AlwaysOnTop)); + Assert.That(loadResult.Settings.FilterList.Count, Is.EqualTo(2)); + Assert.That(loadResult.Settings.SearchHistoryList.Count, Is.EqualTo(2)); + Assert.That(loadResult.Settings.Preferences.HighlightGroupList.Count, Is.EqualTo(1)); + } + + [Test] + [Category("Integration")] + [Description("Multiple save operations should maintain backup chain correctly")] + public void MultipleSaves_MaintainsBackupChain () + { + // Arrange & Act - Save 1 + Settings settings1 = CreateTestSettings(); + settings1.AlwaysOnTop = true; + InvokePrivateInstanceMethod("SaveAsJSON", _testSettingsFile, settings1); + + // Act - Save 2 + Settings settings2 = CreateTestSettings(); + settings2.AlwaysOnTop = false; + settings2.FilterList.Add(new FilterParams { SearchText = "FILTER1" }); + InvokePrivateInstanceMethod("SaveAsJSON", _testSettingsFile, settings2); + + // Act - Save 3 + Settings settings3 = CreateTestSettings(); + settings3.AlwaysOnTop = true; + settings3.FilterList.Add(new FilterParams { SearchText = "FILTER1" }); + settings3.FilterList.Add(new FilterParams { SearchText = "FILTER2" }); + InvokePrivateInstanceMethod("SaveAsJSON", _testSettingsFile, settings3); + + // Assert - Main file has latest + string mainContent = File.ReadAllText(_testSettingsFile.FullName); + Assert.That(mainContent, Does.Contain("FILTER2"), "Main file should have latest settings"); + + // Assert - Backup has previous version + string backupFile = _testSettingsFile.FullName + ".bak"; + Assert.That(File.Exists(backupFile), Is.True, "Backup file should exist"); + + string backupContent = File.ReadAllText(backupFile); + Assert.That(backupContent, Does.Contain("FILTER1"), "Backup should have previous version"); + Assert.That(backupContent, Does.Not.Contain("FILTER2"), "Backup should not have latest changes"); + } + + [Test] + [Category("Integration")] + [Description("Save operation should be atomic - file always in valid state")] + public void Save_IsAtomic_FileAlwaysValid () + { + // Arrange + Settings settings1 = CreateTestSettings(); + settings1.FilterList.Add(new FilterParams { SearchText = "INITIAL" }); + InvokePrivateInstanceMethod("SaveAsJSON", _testSettingsFile, settings1); + + // Act - Save multiple times rapidly + for (int i = 0; i < 10; i++) + { + Settings settings = CreateTestSettings(); + settings.FilterList.Add(new FilterParams { SearchText = $"FILTER_{i}" }); + InvokePrivateInstanceMethod("SaveAsJSON", _testSettingsFile, settings); + + // Verify file is always readable + Assert.That(_testSettingsFile.Exists, Is.True, $"File should exist after save {i}"); + Assert.DoesNotThrow(() => + { + string json = File.ReadAllText(_testSettingsFile.FullName); + Settings? loaded = JsonConvert.DeserializeObject(json); + Assert.That(loaded, Is.Not.Null); + }, $"File should always be valid JSON after save {i}"); + } + } + + [Test] + [Category("Integration")] + [Description("Backup file should always be valid when it exists")] + public void BackupFile_AlwaysValid_WhenExists () + { + // Arrange & Act + for (int i = 0; i < 5; i++) + { + Settings settings = CreateTestSettings(); + settings.FilterList.Add(new FilterParams { SearchText = $"FILTER_{i}" }); + InvokePrivateInstanceMethod("SaveAsJSON", _testSettingsFile, settings); + + // Check backup if it exists + string backupFile = _testSettingsFile.FullName + ".bak"; + if (File.Exists(backupFile)) + { + Assert.DoesNotThrow(() => + { + string json = File.ReadAllText(backupFile); + Settings? loaded = JsonConvert.DeserializeObject(json); + Assert.That(loaded, Is.Not.Null, $"Backup should be valid JSON after save {i}"); + }, $"Backup file should always be valid when it exists (iteration {i})"); + } + } + } + + #endregion +} diff --git a/src/LogExpert.UI/Controls/DateTimeDragControl.cs b/src/LogExpert.UI/Controls/DateTimeDragControl.cs index bcc2c796..5856c746 100644 --- a/src/LogExpert.UI/Controls/DateTimeDragControl.cs +++ b/src/LogExpert.UI/Controls/DateTimeDragControl.cs @@ -29,7 +29,7 @@ internal partial class DateTimeDragControl : UserControl private readonly StringFormat _digitsFormat = new(); private int _draggedDigit; - private DragOrientationsEnum _dragOrientation = DragOrientationsEnum.Vertical; + private DragOrientations _dragOrientation = DragOrientations.Vertical; private readonly ToolStripItem toolStripItemHorizontalDrag = new ToolStripMenuItem(); private readonly ToolStripItem toolStripItemVerticalDrag = new ToolStripMenuItem(); @@ -87,7 +87,7 @@ public DateTimeDragControl () public DateTime MaxDateTime { get; set; } = DateTime.MaxValue; - public DragOrientationsEnum DragOrientation + public DragOrientations DragOrientation { get => _dragOrientation; set @@ -319,9 +319,9 @@ private void BuildContextualMenu () private void UpdateContextMenu () { - toolStripItemHorizontalDrag.Enabled = DragOrientation != DragOrientationsEnum.Horizontal; - toolStripItemVerticalDrag.Enabled = DragOrientation != DragOrientationsEnum.Vertical; - toolStripItemVerticalInvertedDrag.Enabled = DragOrientation != DragOrientationsEnum.InvertedVertical; + toolStripItemHorizontalDrag.Enabled = DragOrientation != DragOrientations.Horizontal; + toolStripItemVerticalDrag.Enabled = DragOrientation != DragOrientations.Vertical; + toolStripItemVerticalInvertedDrag.Enabled = DragOrientation != DragOrientations.InvertedVertical; } private void OnContextMenuStripOpening (object sender, CancelEventArgs e) @@ -334,7 +334,7 @@ private void OnContextMenuStripOpening (object sender, CancelEventArgs e) private void OnToolStripItemHorizontalDragClick (object sender, EventArgs e) { - DragOrientation = DragOrientationsEnum.Horizontal; + DragOrientation = DragOrientations.Horizontal; toolStripItemHorizontalDrag.Enabled = false; toolStripItemVerticalDrag.Enabled = true; toolStripItemVerticalInvertedDrag.Enabled = true; @@ -342,7 +342,7 @@ private void OnToolStripItemHorizontalDragClick (object sender, EventArgs e) private void OnToolStripItemVerticalDragClick (object sender, EventArgs e) { - DragOrientation = DragOrientationsEnum.Vertical; + DragOrientation = DragOrientations.Vertical; toolStripItemHorizontalDrag.Enabled = true; toolStripItemVerticalDrag.Enabled = false; toolStripItemVerticalInvertedDrag.Enabled = true; @@ -350,7 +350,7 @@ private void OnToolStripItemVerticalDragClick (object sender, EventArgs e) private void OnToolStripItemVerticalInvertedDragClick (object sender, EventArgs e) { - DragOrientation = DragOrientationsEnum.InvertedVertical; + DragOrientation = DragOrientations.InvertedVertical; toolStripItemHorizontalDrag.Enabled = true; toolStripItemVerticalDrag.Enabled = true; toolStripItemVerticalInvertedDrag.Enabled = false; @@ -466,12 +466,12 @@ protected override void OnMouseMove (MouseEventArgs e) int diff; switch (DragOrientation) { - case DragOrientationsEnum.Vertical: + case DragOrientations.Vertical: { diff = _startMouseY - e.Y; break; } - case DragOrientationsEnum.InvertedVertical: + case DragOrientations.InvertedVertical: { diff = _startMouseY + e.Y; break; diff --git a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.designer.cs b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.designer.cs index 2b322b27..59eb0f7d 100644 --- a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.designer.cs +++ b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.designer.cs @@ -1126,7 +1126,7 @@ private void InitializeComponent() dragControlDateTime.Anchor = AnchorStyles.Bottom | AnchorStyles.Right; dragControlDateTime.BackColor = System.Drawing.SystemColors.Control; dragControlDateTime.DateTime = new System.DateTime(0L); - dragControlDateTime.DragOrientation = DragOrientationsEnum.Vertical; + dragControlDateTime.DragOrientation = DragOrientations.Vertical; dragControlDateTime.Font = new System.Drawing.Font("Courier New", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, 0); dragControlDateTime.ForeColor = System.Drawing.SystemColors.ControlDarkDark; dragControlDateTime.HoverColor = System.Drawing.Color.LightGray; diff --git a/src/LogExpert.UI/Dialogs/SettingsDialog.cs b/src/LogExpert.UI/Dialogs/SettingsDialog.cs index 5a76f8c0..b7c928fa 100644 --- a/src/LogExpert.UI/Dialogs/SettingsDialog.cs +++ b/src/LogExpert.UI/Dialogs/SettingsDialog.cs @@ -80,9 +80,9 @@ private void FillDialog () checkBoxFilterTail.Checked = Preferences.FilterTail; checkBoxFollowTail.Checked = Preferences.FollowTail; - radioButtonHorizMouseDrag.Checked = Preferences.TimestampControlDragOrientation == DragOrientationsEnum.Horizontal; - radioButtonVerticalMouseDrag.Checked = Preferences.TimestampControlDragOrientation == DragOrientationsEnum.Vertical; - radioButtonVerticalMouseDragInverted.Checked = Preferences.TimestampControlDragOrientation == DragOrientationsEnum.InvertedVertical; + radioButtonHorizMouseDrag.Checked = Preferences.TimestampControlDragOrientation == DragOrientations.Horizontal; + radioButtonVerticalMouseDrag.Checked = Preferences.TimestampControlDragOrientation == DragOrientations.Vertical; + radioButtonVerticalMouseDragInverted.Checked = Preferences.TimestampControlDragOrientation == DragOrientations.InvertedVertical; checkBoxSingleInstance.Checked = Preferences.AllowOnlyOneInstance; checkBoxOpenLastFiles.Checked = Preferences.OpenLastFiles; @@ -212,10 +212,12 @@ private void SaveMultifileData () private void OnBtnToolClickInternal (TextBox textBox) { - OpenFileDialog dlg = new(); - dlg.InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + OpenFileDialog dlg = new() + { + InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) + }; - if (string.IsNullOrEmpty(textBox.Text) == false) + if (!string.IsNullOrEmpty(textBox.Text)) { FileInfo info = new(textBox.Text); if (info.Directory != null && info.Directory.Exists) @@ -309,7 +311,7 @@ private void FillColumnizerList () foreach (var columnizer in columnizers) { - comboColumn.Items.Add(columnizer.GetName()); + _ = comboColumn.Items.Add(columnizer.GetName()); } //comboColumn.DisplayMember = "Name"; //comboColumn.ValueMember = "Columnizer"; @@ -317,21 +319,21 @@ private void FillColumnizerList () foreach (var maskEntry in Preferences.ColumnizerMaskList) { DataGridViewRow row = new(); - row.Cells.Add(new DataGridViewTextBoxCell()); + _ = row.Cells.Add(new DataGridViewTextBoxCell()); DataGridViewComboBoxCell cell = new(); foreach (var logColumnizer in columnizers) { - cell.Items.Add(logColumnizer.GetName()); + _ = cell.Items.Add(logColumnizer.GetName()); } - row.Cells.Add(cell); + _ = row.Cells.Add(cell); row.Cells[0].Value = maskEntry.Mask; var columnizer = ColumnizerPicker.DecideColumnizerByName(maskEntry.ColumnizerName, PluginRegistry.PluginRegistry.Instance.RegisteredColumnizers); row.Cells[1].Value = columnizer.GetName(); - dataGridViewColumnizer.Rows.Add(row); + _ = dataGridViewColumnizer.Rows.Add(row); } var count = dataGridViewColumnizer.RowCount; @@ -355,21 +357,21 @@ private void FillHighlightMaskList () foreach (var group in (IList)_logTabWin.HighlightGroupList) { - comboColumn.Items.Add(group.GroupName); + _ = comboColumn.Items.Add(group.GroupName); } foreach (var maskEntry in Preferences.HighlightMaskList) { DataGridViewRow row = new(); - row.Cells.Add(new DataGridViewTextBoxCell()); + _ = row.Cells.Add(new DataGridViewTextBoxCell()); DataGridViewComboBoxCell cell = new(); foreach (var group in (IList)_logTabWin.HighlightGroupList) { - cell.Items.Add(group.GroupName); + _ = cell.Items.Add(group.GroupName); } - row.Cells.Add(cell); + _ = row.Cells.Add(cell); row.Cells[0].Value = maskEntry.Mask; var currentGroup = _logTabWin.FindHighlightGroup(maskEntry.HighlightGroupName); @@ -377,7 +379,7 @@ private void FillHighlightMaskList () currentGroup ??= highlightGroupList.Count > 0 ? highlightGroupList[0] : new HighlightGroup(); row.Cells[1].Value = currentGroup.GroupName; - dataGridViewHighlightMask.Rows.Add(row); + _ = dataGridViewHighlightMask.Rows.Add(row); } var count = dataGridViewHighlightMask.RowCount; @@ -428,7 +430,7 @@ private void FillPluginList () foreach (var entry in PluginRegistry.PluginRegistry.Instance.RegisteredContextMenuPlugins) { - listBoxPlugin.Items.Add(entry); + _ = listBoxPlugin.Items.Add(entry); if (entry is ILogExpertPluginConfigurator configurator) { configurator.StartConfig(); @@ -437,7 +439,7 @@ private void FillPluginList () foreach (var entry in PluginRegistry.PluginRegistry.Instance.RegisteredKeywordActions) { - listBoxPlugin.Items.Add(entry); + _ = listBoxPlugin.Items.Add(entry); if (entry is ILogExpertPluginConfigurator configurator) { configurator.StartConfig(); @@ -446,7 +448,7 @@ private void FillPluginList () foreach (var entry in PluginRegistry.PluginRegistry.Instance.RegisteredFileSystemPlugins) { - listBoxPlugin.Items.Add(entry); + _ = listBoxPlugin.Items.Add(entry); if (entry is ILogExpertPluginConfigurator configurator) { configurator.StartConfig(); @@ -483,7 +485,7 @@ private void FillToolListbox () foreach (var tool in Preferences.ToolEntries) { - listBoxTools.Items.Add(tool.Clone(), tool.IsFavourite); + _ = listBoxTools.Items.Add(tool.Clone(), tool.IsFavourite); } if (listBoxTools.Items.Count > 0) @@ -565,7 +567,7 @@ private void DisplayCurrentIcon () { Image image = icon.ToBitmap(); buttonIcon.Image = image; - NativeMethods.DestroyIcon(icon.Handle); + _ = NativeMethods.DestroyIcon(icon.Handle); icon.Dispose(); } else @@ -579,12 +581,12 @@ private void FillEncodingList () { comboBoxEncoding.Items.Clear(); - comboBoxEncoding.Items.Add(Encoding.ASCII); - comboBoxEncoding.Items.Add(Encoding.Default); - comboBoxEncoding.Items.Add(Encoding.GetEncoding("iso-8859-1")); - comboBoxEncoding.Items.Add(Encoding.UTF8); - comboBoxEncoding.Items.Add(Encoding.Unicode); - comboBoxEncoding.Items.Add(CodePagesEncodingProvider.Instance.GetEncoding(1252)); + _ = comboBoxEncoding.Items.Add(Encoding.ASCII); + _ = comboBoxEncoding.Items.Add(Encoding.Default); + _ = comboBoxEncoding.Items.Add(Encoding.GetEncoding("iso-8859-1")); + _ = comboBoxEncoding.Items.Add(Encoding.UTF8); + _ = comboBoxEncoding.Items.Add(Encoding.Unicode); + _ = comboBoxEncoding.Items.Add(CodePagesEncodingProvider.Instance.GetEncoding(1252)); comboBoxEncoding.ValueMember = "HeaderName"; } @@ -624,18 +626,11 @@ private void OnBtnOkClick (object sender, EventArgs e) Preferences.FilterTail = checkBoxFilterTail.Checked; Preferences.FollowTail = checkBoxFollowTail.Checked; - if (radioButtonVerticalMouseDrag.Checked) - { - Preferences.TimestampControlDragOrientation = DragOrientationsEnum.Vertical; - } - else if (radioButtonVerticalMouseDragInverted.Checked) - { - Preferences.TimestampControlDragOrientation = DragOrientationsEnum.InvertedVertical; - } - else - { - Preferences.TimestampControlDragOrientation = DragOrientationsEnum.Horizontal; - } + Preferences.TimestampControlDragOrientation = radioButtonVerticalMouseDrag.Checked + ? DragOrientations.Vertical + : radioButtonVerticalMouseDragInverted.Checked + ? DragOrientations.InvertedVertical + : DragOrientations.Horizontal; SaveColumnizerList(); @@ -708,7 +703,7 @@ private void OnDataGridViewColumnizerRowsAdded (object sender, DataGridViewRowsA var comboCell = (DataGridViewComboBoxCell)dataGridViewColumnizer.Rows[e.RowIndex].Cells[1]; if (comboCell.Items.Count > 0) { - // comboCell.Value = comboCell.Items[0]; + //comboCell.Value = comboCell.Items[0]; } } @@ -717,7 +712,7 @@ private void OnBtnDeleteClick (object sender, EventArgs e) if (dataGridViewColumnizer.CurrentRow != null && !dataGridViewColumnizer.CurrentRow.IsNewRow) { var index = dataGridViewColumnizer.CurrentRow.Index; - dataGridViewColumnizer.EndEdit(); + _ = dataGridViewColumnizer.EndEdit(); dataGridViewColumnizer.Rows.RemoveAt(index); } } @@ -767,14 +762,14 @@ private void OnListBoxPluginSelectedIndexChanged (object sender, EventArgs e) { _selectedPlugin?.HideConfigForm(); - var o = listBoxPlugin.SelectedItem; + var selectedPlugin = listBoxPlugin.SelectedItem; - if (o != null) + if (selectedPlugin != null) { - _selectedPlugin = o as ILogExpertPluginConfigurator; - - if (o is ILogExpertPluginConfigurator) + if (selectedPlugin is ILogExpertPluginConfigurator) { + _selectedPlugin = selectedPlugin as ILogExpertPluginConfigurator; + if (_selectedPlugin.HasEmbeddedForm()) { buttonConfigPlugin.Enabled = false; @@ -823,7 +818,7 @@ private void OnPortableModeCheckedChanged (object sender, EventArgs e) { if (Directory.Exists(ConfigManager.PortableModeDir) == false) { - Directory.CreateDirectory(ConfigManager.PortableModeDir); + _ = Directory.CreateDirectory(ConfigManager.PortableModeDir); } using (File.Create(ConfigManager.PortableModeDir + Path.DirectorySeparatorChar + ConfigManager.PortableModeSettingsFileName)) @@ -857,9 +852,8 @@ private void OnPortableModeCheckedChanged (object sender, EventArgs e) } catch (Exception exception) { - MessageBox.Show($@"Could not create / delete marker for Portable Mode: {exception}", @"Error", MessageBoxButtons.OK); + _ = MessageBox.Show($@"Could not create / delete marker for Portable Mode: {exception}", @"Error", MessageBoxButtons.OK); } - } private void OnBtnConfigPluginClick (object sender, EventArgs e) @@ -894,7 +888,9 @@ private void OnBtnToolUpClick (object sender, EventArgs e) var isChecked = listBoxTools.GetItemChecked(i); var item = listBoxTools.Items[i]; listBoxTools.Items.RemoveAt(i); + i--; + listBoxTools.Items.Insert(i, item); listBoxTools.SelectedIndex = i; listBoxTools.SetItemChecked(i, isChecked); @@ -910,7 +906,9 @@ private void OnBtnToolDownClick (object sender, EventArgs e) var isChecked = listBoxTools.GetItemChecked(i); var item = listBoxTools.Items[i]; listBoxTools.Items.RemoveAt(i); + i++; + listBoxTools.Items.Insert(i, item); listBoxTools.SelectedIndex = i; listBoxTools.SetItemChecked(i, isChecked); @@ -920,7 +918,7 @@ private void OnBtnToolDownClick (object sender, EventArgs e) [SupportedOSPlatform("windows")] private void OnBtnToolAddClick (object sender, EventArgs e) { - listBoxTools.Items.Add(new ToolEntry()); + _ = listBoxTools.Items.Add(new ToolEntry()); listBoxTools.SelectedIndex = listBoxTools.Items.Count - 1; } @@ -1029,14 +1027,51 @@ private void OnBtnImportClick (object sender, EventArgs e) } catch (Exception ex) { - MessageBox.Show(this, $@"Settings could not be imported: {ex}", @"LogExpert"); + _ = MessageBox.Show(this, $@"Settings could not be imported: {ex}", @"LogExpert"); return; } - ConfigManager.Import(fileInfo, dlg.ImportFlags); + ImportResult importResult = ConfigManager.Import(fileInfo, dlg.ImportFlags); + + if (!importResult.Success) + { + if (importResult.RequiresUserConfirmation) + { + var confirmResult = MessageBox.Show( + this, + importResult.ConfirmationMessage, + importResult.ConfirmationTitle, + MessageBoxButtons.YesNo, + MessageBoxIcon.Warning, + MessageBoxDefaultButton.Button2); + + if (confirmResult == DialogResult.Yes) + { + // User confirmed, retry import without validation + _ = ConfigManager.Import(fileInfo, dlg.ImportFlags); + } + else + { + return; + } + } + else + { + _ = MessageBox.Show( + this, + importResult.ErrorMessage, + importResult.ErrorTitle, + MessageBoxButtons.OK, + MessageBoxIcon.Error); + + return; + } + } + Preferences = ConfigManager.Settings.Preferences; FillDialog(); - MessageBox.Show(this, @"Settings imported", @"LogExpert"); + + _ = MessageBox.Show(this, @"Settings imported", @"LogExpert"); } } diff --git a/src/LogExpert/Config/ConfigManager.cs b/src/LogExpert/Config/ConfigManager.cs index f9277f88..769ea11f 100644 --- a/src/LogExpert/Config/ConfigManager.cs +++ b/src/LogExpert/Config/ConfigManager.cs @@ -1,10 +1,12 @@ using System.Drawing; using System.Globalization; using System.Reflection; +using System.Security; using System.Text; using System.Windows.Forms; using LogExpert.Core.Classes; +using LogExpert.Core.Classes.Persister; using LogExpert.Core.Config; using LogExpert.Core.Entities; using LogExpert.Core.EventArguments; @@ -20,13 +22,26 @@ public class ConfigManager : IConfigManager { #region Fields - private static readonly ILogger _logger = LogManager.GetCurrentClassLogger(); + private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); private static readonly object _monitor = new(); private static ConfigManager _instance; private readonly object _loadSaveLock = new(); private Settings _settings; + private static readonly JsonSerializerSettings _jsonSettings = new() + { + Converters = + { + new ColumnizerJsonConverter(), + new EncodingJsonConverter() + }, + Formatting = Formatting.Indented, + //This is needed for the BookmarkList and the Bookmark Overlay + ReferenceLoopHandling = ReferenceLoopHandling.Serialize, + PreserveReferencesHandling = PreserveReferencesHandling.Objects, + }; + #endregion #region cTor @@ -76,7 +91,7 @@ public static ConfigManager Instance IConfigManager IConfigManager.Instance => Instance; - // Action IConfigManager.ConfigChanged { get => ((IConfigManager)_instance).ConfigChanged; set => ((IConfigManager)_instance).ConfigChanged = value; } + //Action IConfigManager.ConfigChanged { get => ((IConfigManager)_instance).ConfigChanged; set => ((IConfigManager)_instance).ConfigChanged = value; } //public string PortableModeSettingsFileName => ((IConfigManager)_instance).PortableModeSettingsFileName; @@ -84,30 +99,117 @@ public static ConfigManager Instance #region Public methods + /// + /// Saves the current settings with the specified flags. + /// + /// The method saves the settings based on the provided . Ensure that the + /// flags are correctly set to avoid saving unintended settings. + /// The flags that determine which settings to save. This parameter cannot be null. public void Save (SettingsFlags flags) { Instance.Save(Settings, flags); } + /// + /// Exports the current instance data to the specified file. + /// + /// The method saves the current instance data using the provided settings. Ensure that the file + /// path specified in is accessible and writable. + /// The object representing the file to which the data will be exported. Cannot be null. public void Export (FileInfo fileInfo) { Instance.Save(fileInfo, Settings); } + /// + /// Exports only the highlight settings to the specified file. + /// + /// + /// public void Export (FileInfo fileInfo, SettingsFlags highlightSettings) { Instance.Save(fileInfo, Settings, highlightSettings); } - public void Import (FileInfo fileInfo, ExportImportFlags importFlags) + /// + /// Import settings from a file. + /// Returns ImportResult indicating success, error, or user confirmation requirement. + /// + /// The file to import from + /// Flags controlling what to import + /// ImportResult with operation outcome + public ImportResult Import (FileInfo fileInfo, ExportImportFlags importFlags) { + _logger.Info($"Importing settings from: {fileInfo?.FullName ?? "null"}"); + + // Validate import file exists + if (fileInfo == null || !fileInfo.Exists) + { + _logger.Error($"Import file does not exist: {fileInfo?.FullName ?? "null"}"); + return ImportResult.Failed("Import Failed", $"Import file not found:\n{fileInfo?.FullName ?? "unknown"}"); + } + + // Try to load and validate the import file before applying + Settings importedSettings; + try + { + _logger.Info("Validating import file..."); + LoadResult loadResult = LoadOrCreateNew(fileInfo); + + // Handle any critical errors from loading + if (loadResult.CriticalFailure) + { + return ImportResult.Failed("Import Failed", $"Import file is invalid or corrupted:\n\n{loadResult.CriticalMessage}\n\nImport cancelled."); + } + + importedSettings = loadResult.Settings; + } + catch (Exception ex) when (ex is InvalidDataException or + JsonSerializationException) + { + _logger.Error($"Import file is invalid or corrupted: {ex}"); + return ImportResult.Failed("Import Failed", $"Import file is invalid or corrupted:\n\n{ex.Message}\n\nImport cancelled."); + } + + if (SettingsAreEmptyOrDefault(importedSettings)) + { + _logger.Warn("Import file appears to contain empty or default settings"); + + string confirmationMessage = + "Warning: Import file appears to be empty or contains default settings.\n\n" + + "This will overwrite your current configuration with empty settings.\n\n" + + $"Import file: {fileInfo.Name}\n" + + $"Filters: {importedSettings.FilterList?.Count ?? 0}\n" + + $"History: {importedSettings.FileHistoryList?.Count ?? 0}\n" + + $"Highlights: {importedSettings.Preferences?.HighlightGroupList?.Count ?? 0}\n\n" + + "Continue with import?"; + + return ImportResult.RequiresConfirmation("Confirm Import", confirmationMessage); + } + + // Log what we're importing + _logger.Info($"Importing: Filters={importedSettings.FilterList?.Count ?? 0}, " + + $"History={importedSettings.FileHistoryList?.Count ?? 0}, " + + $"Highlights={importedSettings.Preferences?.HighlightGroupList?.Count ?? 0}"); + + // Proceed with import Instance._settings = Instance.Import(Instance._settings, fileInfo, importFlags); Save(SettingsFlags.All); + + _logger.Info("Import completed successfully"); + return ImportResult.Successful(); } + /// + /// Imports only the highlight settings from the specified file. + /// + /// + /// public void ImportHighlightSettings (FileInfo fileInfo, ExportImportFlags importFlags) { - Instance._settings.Preferences.HighlightGroupList = Instance.Import(Instance._settings.Preferences.HighlightGroupList, fileInfo, importFlags); + ArgumentNullException.ThrowIfNull(fileInfo, nameof(fileInfo)); + + Instance.Settings.Preferences.HighlightGroupList = Import(Instance.Settings.Preferences.HighlightGroupList, fileInfo, importFlags); Save(SettingsFlags.All); } @@ -115,6 +217,10 @@ public void ImportHighlightSettings (FileInfo fileInfo, ExportImportFlags import #region Private Methods + /// + /// Loads the Settings from file or creates new settings if the file does not exist. + /// + /// private Settings Load () { _logger.Info(CultureInfo.InvariantCulture, "Loading settings"); @@ -137,148 +243,326 @@ private Settings Load () _ = Directory.CreateDirectory(dir); } - if (!File.Exists(Path.Combine(dir, "settings.json"))) - { - return LoadOrCreateNew(null); - } + LoadResult result; - try + if (!File.Exists(Path.Combine(dir, "settings.json"))) { - FileInfo fileInfo = new(Path.Combine(dir, "settings.json")); - return LoadOrCreateNew(fileInfo); + result = LoadOrCreateNew(null); } - catch (IOException ex) + else { - _logger.Error($"File system error: {ex.Message}"); + try + { + FileInfo fileInfo = new(Path.Combine(dir, "settings.json")); + result = LoadOrCreateNew(fileInfo); + } + catch (IOException ex) + { + _logger.Error($"File system error: {ex.Message}"); + result = LoadOrCreateNew(null); + } + catch (UnauthorizedAccessException ex) + { + _logger.Error($"Access denied: {ex.Message}"); + result = LoadOrCreateNew(null); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.Error($"Unexpected error: {ex.Message}"); + result = LoadOrCreateNew(null); + } } - catch (UnauthorizedAccessException ex) + + // Handle recovery notifications (if loaded from backup) + if (result.LoadedFromBackup) { - _logger.Error($"Access denied: {ex.Message}"); + _logger.Info("Settings recovered from backup, notification pending"); + // Store recovery information for UI layer to display + // Note: MessageBox should be shown by UI layer after initialization + OnSettingsRecoveredFromBackup(result.RecoveryTitle, result.RecoveryMessage); } - catch (Exception ex) when (ex is not OperationCanceledException) + + // Handle critical failures + if (result.CriticalFailure) { - _logger.Error($"Unexpected error: {ex.Message}"); + _logger.Error("Critical settings load failure, user decision required"); + // Store critical error for UI layer to display + // Note: MessageBox should be shown by UI layer after initialization + OnCriticalSettingsFailure(result.CriticalTitle, result.CriticalMessage); } - return LoadOrCreateNew(null); + return result.Settings; + } + /// + /// Event raised when settings are recovered from backup (UI layer should show notification) + /// + private void OnSettingsRecoveredFromBackup (string title, string message) + { + // UI layer should subscribe to this or check status after ConfigManager initialization + // For now, just log - proper event mechanism can be added later + _logger.Warn($"Recovery notification: {title} - {message}"); } /// - /// Loads Settings of a given file or creates new settings if the file does not exist + /// Event raised when critical settings failure occurs (UI layer should show error and get user choice) + /// + private void OnCriticalSettingsFailure (string title, string message) + { + // UI layer should subscribe to this or check status after ConfigManager initialization + // For now, default to creating new settings - proper event mechanism can be added later + _logger.Error($"Critical failure: {title} - {message}"); + _logger.Warn("Defaulting to create new settings (UI layer should prompt user)"); + } + + /// + /// Loads Settings of a given file or creates new settings if the file does not exist. + /// Includes automatic backup recovery if main file is corrupted. + /// Returns LoadResult with the settings and any recovery information. /// /// file that has settings saved - /// loaded or created settings - private Settings LoadOrCreateNew (FileInfo fileInfo) + /// LoadResult containing loaded/created settings and status + /// + /// + private LoadResult LoadOrCreateNew (FileInfo fileInfo) { + //TODO this needs to be refactor, its quite big lock (_loadSaveLock) { - Settings settings; + Settings settings = null; + Exception loadException = null; - if (fileInfo == null || fileInfo.Exists == false) + if (fileInfo == null || !fileInfo.Exists) { + _logger.Info("No settings file found, creating new default settings"); settings = new Settings(); } else { + // Try loading main settings file try { - settings = JsonConvert.DeserializeObject(File.ReadAllText($"{fileInfo.FullName}")); + _logger.Info($"Loading settings from: {fileInfo.FullName}"); + string json = File.ReadAllText(fileInfo.FullName); + + if (string.IsNullOrWhiteSpace(json)) + { + throw new InvalidDataException("Settings file is empty"); + } + + settings = JsonConvert.DeserializeObject(json, _jsonSettings); + + if (settings == null) + { + throw new JsonSerializationException("Deserialization returned null"); + } + + _logger.Info("Settings loaded successfully"); } - catch (Exception e) + catch (Exception e) when (e is ArgumentException or + ArgumentNullException or + DirectoryNotFoundException or + FileNotFoundException or + IOException or + InvalidDataException or + NotSupportedException or + PathTooLongException or + UnauthorizedAccessException or + SecurityException or + JsonException or + JsonSerializationException or + JsonReaderException) { - _logger.Error($"Error while deserializing config data: {e}"); - settings = new Settings(); + _logger.Error($"Error deserializing settings.json: {e}"); + loadException = e; + + // Try loading from backup file + string backupFile = fileInfo.FullName + ".bak"; + if (File.Exists(backupFile)) + { + try + { + _logger.Warn($"Attempting to load from backup file: {backupFile}"); + string backupJson = File.ReadAllText(backupFile); + + if (!string.IsNullOrWhiteSpace(backupJson)) + { + settings = JsonConvert.DeserializeObject(backupJson, _jsonSettings); + + if (settings != null) + { + _logger.Info("Settings recovered from backup successfully"); + + // Save corrupted file for analysis + string corruptFile = fileInfo.FullName + ".corrupt"; + try + { + File.Copy(fileInfo.FullName, corruptFile, overwrite: true); + _logger.Info($"Corrupted file saved to: {corruptFile}"); + } + catch (Exception copyException) when (copyException is ArgumentException or + ArgumentNullException or + DirectoryNotFoundException or + FileNotFoundException or + IOException or + NotSupportedException or + PathTooLongException or + UnauthorizedAccessException) + + { + _logger.Warn($"Could not save corrupted file: {copyException.Message}"); + } + + // Return recovery result instead of showing MessageBox + settings = InitializeSettings(settings); + return LoadResult.FromBackup( + settings, + "Settings file was corrupted but recovered from backup.\n\n" + + $"Original error: {e.Message}\n\n" + + $"A copy of the corrupted file has been saved as:\n{corruptFile}", + "Settings Recovered from Backup"); + } + } + } + catch (Exception backupException) when (backupException is ArgumentException or + ArgumentNullException or + DirectoryNotFoundException or + FileNotFoundException or + IOException or + NotSupportedException or + PathTooLongException or + UnauthorizedAccessException or + SecurityException) + { + _logger.Error($"Backup file also corrupted: {backupException}"); + } + } + else + { + _logger.Error("No backup file available for recovery"); + } } } - settings.Preferences ??= new Preferences(); + // If all loading attempts failed, return critical failure result + if (settings == null) + { + if (loadException != null) + { + _logger.Error("All attempts to load settings failed"); - settings.Preferences.ToolEntries ??= []; + // Create new settings for critical failure case + settings = new Settings(); + settings = InitializeSettings(settings); + + return LoadResult.Critical( + settings, + "Critical: Settings Load Failed", + "Failed to load settings file. All configuration will be lost if you continue.\n\n" + + $"Error: {loadException.Message}\n\n" + + "Do you want to:\n" + + "YES - Create new settings (loses all configuration)\n" + + "NO - Exit application (allows manual recovery)\n\n" + + "Your corrupted settings file will be preserved for manual recovery."); + } - settings.Preferences.ColumnizerMaskList ??= []; + settings = new Settings(); + } - settings.FileHistoryList ??= []; + settings = InitializeSettings(settings); + return LoadResult.Success(settings); + } + } - settings.LastOpenFilesList ??= []; + /// + /// Initialize settings with required default values + /// + private static Settings InitializeSettings (Settings settings) + { + settings.Preferences ??= new Preferences(); + settings.Preferences.ToolEntries ??= []; + settings.Preferences.ColumnizerMaskList ??= []; - settings.FileColors ??= []; + settings.FileHistoryList ??= []; - try - { - using var fontFamily = new FontFamily(settings.Preferences.FontName); - settings.Preferences.FontName = fontFamily.Name; - } - catch (ArgumentException) - { - var genericMonospaceFont = FontFamily.GenericMonospace.Name; - _logger.Warn($"Specified font '{settings.Preferences.FontName}' not found. Falling back to default: '{genericMonospaceFont}'."); - settings.Preferences.FontName = genericMonospaceFont; - } + settings.LastOpenFilesList ??= []; - if (settings.Preferences.ShowTailColor == Color.Empty) - { - settings.Preferences.ShowTailColor = Color.FromKnownColor(KnownColor.Blue); - } + settings.FileColors ??= []; - if (settings.Preferences.TimeSpreadColor == Color.Empty) - { - settings.Preferences.TimeSpreadColor = Color.Gray; - } + try + { + using var fontFamily = new FontFamily(settings.Preferences.FontName); + settings.Preferences.FontName = fontFamily.Name; + } + catch (ArgumentException) + { + string genericMonospaceFont = FontFamily.GenericMonospace.Name; + _logger.Warn($"Specified font '{settings.Preferences.FontName}' not found. Falling back to default: '{genericMonospaceFont}'."); + settings.Preferences.FontName = genericMonospaceFont; + } - if (settings.Preferences.BufferCount < 10) - { - settings.Preferences.BufferCount = 100; - } + if (settings.Preferences.ShowTailColor == Color.Empty) + { + settings.Preferences.ShowTailColor = Color.FromKnownColor(KnownColor.Blue); + } - if (settings.Preferences.LinesPerBuffer < 1) - { - settings.Preferences.LinesPerBuffer = 500; - } + if (settings.Preferences.TimeSpreadColor == Color.Empty) + { + settings.Preferences.TimeSpreadColor = Color.Gray; + } - settings.FilterList ??= []; + if (settings.Preferences.BufferCount < 10) + { + settings.Preferences.BufferCount = 100; + } - settings.SearchHistoryList ??= []; + if (settings.Preferences.LinesPerBuffer < 1) + { + settings.Preferences.LinesPerBuffer = 500; + } - settings.FilterHistoryList ??= []; + settings.FilterList ??= []; - settings.FilterRangeHistoryList ??= []; + settings.SearchHistoryList ??= []; - foreach (var filterParams in settings.FilterList) - { - filterParams.Init(); - } + settings.FilterHistoryList ??= []; - if (settings.Preferences.HighlightGroupList == null) - { - settings.Preferences.HighlightGroupList = []; - } + settings.FilterRangeHistoryList ??= []; - settings.Preferences.HighlightMaskList ??= []; + foreach (Core.Classes.Filter.FilterParams filterParams in settings.FilterList) + { + filterParams.Init(); + } - if (settings.Preferences.PollingInterval < 20) - { - settings.Preferences.PollingInterval = 250; - } + if (settings.Preferences.HighlightGroupList == null) + { + settings.Preferences.HighlightGroupList = []; + } - settings.Preferences.MultiFileOptions ??= new MultiFileOptions(); + settings.Preferences.HighlightMaskList ??= []; - settings.Preferences.DefaultEncoding ??= Encoding.Default.HeaderName; + if (settings.Preferences.PollingInterval < 20) + { + settings.Preferences.PollingInterval = 250; + } - if (settings.Preferences.MaximumFilterEntriesDisplayed == 0) - { - settings.Preferences.MaximumFilterEntriesDisplayed = 20; - } + settings.Preferences.MultiFileOptions ??= new MultiFileOptions(); - if (settings.Preferences.MaximumFilterEntries == 0) - { - settings.Preferences.MaximumFilterEntries = 30; - } + settings.Preferences.DefaultEncoding ??= Encoding.Default.HeaderName; - SetBoundsWithinVirtualScreen(settings); + if (settings.Preferences.MaximumFilterEntriesDisplayed == 0) + { + settings.Preferences.MaximumFilterEntriesDisplayed = 20; + } - return settings; + if (settings.Preferences.MaximumFilterEntries == 0) + { + settings.Preferences.MaximumFilterEntries = 30; } + + SetBoundsWithinVirtualScreen(settings); + + return settings; } /// @@ -291,11 +575,11 @@ private void Save (Settings settings, SettingsFlags flags) lock (_loadSaveLock) { _logger.Info(CultureInfo.InvariantCulture, "Saving settings"); - var dir = Settings.Preferences.PortableMode ? Application.StartupPath : ConfigDir; + string dir = Settings.Preferences.PortableMode ? Application.StartupPath : ConfigDir; if (!Directory.Exists(dir)) { - Directory.CreateDirectory(dir); + _ = Directory.CreateDirectory(dir); } FileInfo fileInfo = new(dir + Path.DirectorySeparatorChar + "settings.json"); @@ -328,13 +612,106 @@ private void Save (FileInfo fileInfo, Settings settings, SettingsFlags flags) OnConfigChanged(flags); } - private static void SaveAsJSON (FileInfo fileInfo, Settings settings) + private void SaveAsJSON (FileInfo fileInfo, Settings settings) { + if (!ValidateSettings(settings)) + { + _logger.Error("Settings validation failed - refusing to save"); + throw new InvalidOperationException("Settings validation failed - refusing to save potentially corrupted data"); + } + settings.VersionBuild = Assembly.GetExecutingAssembly().GetName().Version.Build; + string json = JsonConvert.SerializeObject(settings, _jsonSettings); - using StreamWriter sw = new(fileInfo.Create()); - JsonSerializer serializer = new(); - serializer.Serialize(sw, settings); + _logger.Info($"Saving settings: " + + $"Filters={settings.FilterList?.Count ?? 0}, " + + $"History={settings.FileHistoryList?.Count ?? 0}, " + + $"Highlights={settings.Preferences?.HighlightGroupList?.Count ?? 0}, " + + $"Size={json.Length} bytes"); + + WriteSettingsFile(fileInfo, json); + } + + private static void WriteSettingsFile (FileInfo fileInfo, string json) + { + string tempFile = fileInfo.FullName + ".tmp"; + string backupFile = fileInfo.FullName + ".bak"; + + try + { + _logger.Debug($"Writing to {fileInfo.FullName}"); + File.WriteAllText(tempFile, json, Encoding.UTF8); + + if (File.Exists(fileInfo.FullName)) + { + long existingSize = new FileInfo(fileInfo.FullName).Length; + if (existingSize > 0) + { + File.Copy(fileInfo.FullName, backupFile, overwrite: true); + _logger.Debug($"Created backup: {backupFile} ({existingSize} bytes)"); + } + else + { + _logger.Warn($"Existing settings file is empty ({existingSize} bytes), skipping backup"); + } + } + + File.Move(tempFile, fileInfo.FullName, overwrite: true); + _logger.Info("Settings saved successfully"); + } + catch (Exception ex) + { + _logger.Error($"Failed to save settings: {ex}"); + + // Attempt recovery: restore from backup if main file was corrupted + try + { + if (File.Exists(backupFile)) + { + var mainFileExists = File.Exists(fileInfo.FullName); + var mainFileSize = mainFileExists ? new FileInfo(fileInfo.FullName).Length : 0; + + if (!mainFileExists || mainFileSize == 0) + { + File.Copy(backupFile, fileInfo.FullName, overwrite: true); + _logger.Warn("Settings save failed, restored from backup"); + } + } + } + catch (Exception recoverException) when (recoverException is ArgumentException or + ArgumentNullException or + DirectoryNotFoundException or + FileNotFoundException or + IOException or + NotSupportedException or + PathTooLongException or + UnauthorizedAccessException) + { + _logger.Error($"Failed to recover from backup: {recoverException}"); + } + + throw; + } + finally + { + if (File.Exists(tempFile)) + { + try + { + File.Delete(tempFile); + _logger.Debug($"Cleaned up temp file: {tempFile}"); + } + catch (Exception cleanupException) when (cleanupException is ArgumentException or + DirectoryNotFoundException or + IOException or + NotSupportedException or + PathTooLongException or + UnauthorizedAccessException) + { + _logger.Warn($"Failed to cleanup temp file: {cleanupException.Message}"); + } + } + } } private static void SaveHighlightgroupsAsJSON (FileInfo fileInfo, List groups) @@ -344,7 +721,14 @@ private static void SaveHighlightgroupsAsJSON (FileInfo fileInfo, List Import (List currentGroups, FileInfo fileInfo, ExportImportFlags flags) + /// + /// Imports only the highlight groups from the specified file. + /// + /// + /// + /// + /// + private static List Import (List currentGroups, FileInfo fileInfo, ExportImportFlags flags) { List newGroups; @@ -352,7 +736,15 @@ private List Import (List currentGroups, FileInf { newGroups = JsonConvert.DeserializeObject>(File.ReadAllText($"{fileInfo.FullName}")); } - catch (Exception e) + catch (Exception e) when (e is ArgumentException or + ArgumentNullException or + DirectoryNotFoundException or + FileNotFoundException or + IOException or + NotSupportedException or + PathTooLongException or + UnauthorizedAccessException or + SecurityException) { _logger.Error($"Error while deserializing config data: {e}"); newGroups = []; @@ -380,8 +772,9 @@ private List Import (List currentGroups, FileInf /// Flags to indicate which parts shall be imported private Settings Import (Settings currentSettings, FileInfo fileInfo, ExportImportFlags flags) { - var importSettings = LoadOrCreateNew(fileInfo); - var ownSettings = ObjectClone.Clone(currentSettings); + LoadResult loadResult = LoadOrCreateNew(fileInfo); + Settings importSettings = loadResult.Settings; + Settings ownSettings = ObjectClone.Clone(currentSettings); Settings newSettings; // at first check for 'Other' as this are the most options. @@ -422,29 +815,106 @@ private Settings Import (Settings currentSettings, FileInfo fileInfo, ExportImpo return newSettings; } + /// + /// Replaces the existing list with the new list or keeps existing entries based on the flags. + /// + /// + /// + /// + /// + /// private static List ReplaceOrKeepExisting (ExportImportFlags flags, List existingList, List newList) { - if ((flags & ExportImportFlags.KeepExisting) == ExportImportFlags.KeepExisting) - { - return existingList.Union(newList).ToList(); - } - - return newList; + return (flags & ExportImportFlags.KeepExisting) == ExportImportFlags.KeepExisting + ? [.. existingList.Union(newList)] + : newList; } // Checking if the appBounds values are outside the current virtual screen. // If so, the appBounds values are set to 0. - private void SetBoundsWithinVirtualScreen (Settings settings) + private static void SetBoundsWithinVirtualScreen (Settings settings) { - var vs = SystemInformation.VirtualScreen; + Rectangle vs = SystemInformation.VirtualScreen; if (vs.X + vs.Width < settings.AppBounds.X + settings.AppBounds.Width || vs.Y + vs.Height < settings.AppBounds.Y + settings.AppBounds.Height) { settings.AppBounds = new Rectangle(); } } + + /// + /// Checks if settings object appears to be empty or default. + /// This helps detect corrupted or uninitialized settings files. + /// + /// Settings object to validate + /// True if settings appear empty/default, false if they contain user data + private static bool SettingsAreEmptyOrDefault (Settings settings) + { + if (settings == null) + { + return true; + } + + if (settings.Preferences == null) + { + return true; + } + + var filterCount = settings.FilterList?.Count ?? 0; + var historyCount = settings.FileHistoryList?.Count ?? 0; + var searchHistoryCount = settings.SearchHistoryList?.Count ?? 0; + var highlightCount = settings.Preferences.HighlightGroupList?.Count ?? 0; + var columnizerMaskCount = settings.Preferences.ColumnizerMaskList?.Count ?? 0; + + return filterCount == 0 && + historyCount == 0 && + searchHistoryCount == 0 && + highlightCount == 0 && + columnizerMaskCount == 0; + } + + /// + /// Validates settings object for basic integrity. + /// Logs warnings for suspicious conditions. + /// + /// Settings to validate + /// True if settings pass validation + private bool ValidateSettings (Settings settings) + { + if (settings == null) + { + _logger.Error("Attempted to save null settings"); + return false; + } + + if (settings.Preferences == null) + { + _logger.Error("Settings.Preferences is null"); + return false; + } + + if (SettingsAreEmptyOrDefault(settings)) + { + _logger.Warn("Settings appear to be empty - this may indicate data loss"); + + if (_settings != null && !SettingsAreEmptyOrDefault(_settings)) + { + _logger.Warn($"Previous settings: " + + $"Filters={_settings.FilterList?.Count ?? 0}, " + + $"History={_settings.FileHistoryList?.Count ?? 0}, " + + $"SearchHistory={_settings.SearchHistoryList?.Count ?? 0}, " + + $"Highlights={_settings.Preferences?.HighlightGroupList?.Count ?? 0}"); + } + } + + return true; + } #endregion + /// + /// Fires the ConfigChanged event + /// + /// protected void OnConfigChanged (SettingsFlags flags) { ConfigChanged?.Invoke(this, new ConfigChangedEventArgs(flags)); diff --git a/src/LogExpert/Program.cs b/src/LogExpert/Program.cs index a895f909..47614183 100644 --- a/src/LogExpert/Program.cs +++ b/src/LogExpert/Program.cs @@ -63,7 +63,20 @@ private static void Main (string[] args) //TODO: The config file import and the try catch for the primary instance and secondary instance should be separated functions if (cfgFileInfo.Exists) { - ConfigManager.Instance.Import(cfgFileInfo, ExportImportFlags.All); + ImportResult importResult = ConfigManager.Instance.Import(cfgFileInfo, ExportImportFlags.All); + + // Handle import result + if (!importResult.Success) + { + string message = importResult.RequiresUserConfirmation + ? importResult.ConfirmationMessage + : importResult.ErrorMessage; + string title = importResult.RequiresUserConfirmation + ? importResult.ConfirmationTitle + : importResult.ErrorTitle; + + MessageBox.Show(message, title, MessageBoxButtons.OK, MessageBoxIcon.Warning); + } } else { From fc3b5febb1fbe8b2d6b7583a9566368424e67a67 Mon Sep 17 00:00:00 2001 From: Patrick Bruner Date: Mon, 3 Nov 2025 22:33:06 +0100 Subject: [PATCH 2/6] Update src/LogExpert.Core/Config/ImportResult.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/LogExpert.Core/Config/ImportResult.cs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/LogExpert.Core/Config/ImportResult.cs b/src/LogExpert.Core/Config/ImportResult.cs index 5474630c..ae1d56e3 100644 --- a/src/LogExpert.Core/Config/ImportResult.cs +++ b/src/LogExpert.Core/Config/ImportResult.cs @@ -5,16 +5,39 @@ namespace LogExpert.Core.Config; /// public class ImportResult { + /// + /// Indicates whether the import operation was successful. + /// public bool Success { get; set; } + /// + /// The error message describing why the import failed. + /// Populated when is false and is false. + /// public string ErrorMessage { get; set; } + /// + /// The title for the error message. + /// Populated when is false and is false. + /// public string ErrorTitle { get; set; } + /// + /// Indicates whether the import operation requires user confirmation to proceed. + /// When true, and are populated. + /// public bool RequiresUserConfirmation { get; set; } + /// + /// The message to display when user confirmation is required. + /// Populated when is true. + /// public string ConfirmationMessage { get; set; } + /// + /// The title for the confirmation message. + /// Populated when is true. + /// public string ConfirmationTitle { get; set; } public static ImportResult Successful () => new() { Success = true }; From 262d5604bfb074a4a49e2628ab193a62aadb9a85 Mon Sep 17 00:00:00 2001 From: Patrick Bruner Date: Mon, 3 Nov 2025 22:34:14 +0100 Subject: [PATCH 3/6] Update src/LogExpert.Core/Config/LoadResult.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/LogExpert.Core/Config/LoadResult.cs | 31 +++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/LogExpert.Core/Config/LoadResult.cs b/src/LogExpert.Core/Config/LoadResult.cs index 8b5b0843..ac6260b8 100644 --- a/src/LogExpert.Core/Config/LoadResult.cs +++ b/src/LogExpert.Core/Config/LoadResult.cs @@ -7,13 +7,44 @@ namespace LogExpert.Core.Config; /// public class LoadResult { + /// + /// The loaded settings object. Always populated on success or recovery. + /// public Settings Settings { get; set; } + + /// + /// Indicates whether the settings were loaded from a backup file due to a failure loading the primary file. + /// public bool LoadedFromBackup { get; set; } + + /// + /// A message describing the recovery process. Only meaningful when is true. + /// public string RecoveryMessage { get; set; } + + /// + /// A title for the recovery message dialog. Only meaningful when is true. + /// public string RecoveryTitle { get; set; } + + /// + /// Indicates a critical failure occurred during loading. When true, loading could not complete normally. + /// public bool CriticalFailure { get; set; } + + /// + /// A message describing the critical failure. Only meaningful when is true. + /// public string CriticalMessage { get; set; } + + /// + /// A title for the critical failure dialog. Only meaningful when is true. + /// public string CriticalTitle { get; set; } + + /// + /// Indicates whether user input is required to proceed after a critical failure. + /// public bool RequiresUserChoice { get; set; } public static LoadResult Success(Settings settings) => new() From 0123d9caf9daee9df021a1bbc6babeba7a992128 Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Tue, 4 Nov 2025 18:06:34 +0100 Subject: [PATCH 4/6] review comments --- .../JsonColumnizerPropertyAttribute.cs | 4 +- .../Classes/Filter/FilterParams.cs | 2 +- .../ColumnizerJsonConverter.cs | 4 +- .../EncodingJsonConverter.cs | 19 +- .../Classes/Persister/IPersister.cs | 30 ++ .../Classes/Persister/PersistenceData.cs | 5 +- .../Classes/Persister/Persister.cs | 1 + .../Classes/Persister/PersisterXML.cs | 288 ++++++++++++++++-- .../Classes/Persister/ProjectPersister.cs | 18 +- .../Classes/Persister/ProjectPersisterXML.cs | 52 +++- src/LogExpert.Core/Config/LoadResult.cs | 64 +++- .../ColumnizerJsonConverterTests.cs | 3 +- src/LogExpert.Tests/ConfigManagerTest.cs | 45 ++- .../Dialogs/LogTabWindow/LogTabWindow.cs | 2 + src/LogExpert.UI/Dialogs/SettingsDialog.cs | 4 +- src/LogExpert/Config/ConfigManager.cs | 55 +--- src/LogExpert/Program.cs | 31 +- 17 files changed, 483 insertions(+), 144 deletions(-) rename src/LogExpert.Core/Classes/{Persister => Attributes}/JsonColumnizerPropertyAttribute.cs (57%) rename src/LogExpert.Core/Classes/{Persister => JsonConverters}/ColumnizerJsonConverter.cs (97%) rename src/LogExpert.Core/Classes/{Persister => JsonConverters}/EncodingJsonConverter.cs (54%) create mode 100644 src/LogExpert.Core/Classes/Persister/IPersister.cs diff --git a/src/LogExpert.Core/Classes/Persister/JsonColumnizerPropertyAttribute.cs b/src/LogExpert.Core/Classes/Attributes/JsonColumnizerPropertyAttribute.cs similarity index 57% rename from src/LogExpert.Core/Classes/Persister/JsonColumnizerPropertyAttribute.cs rename to src/LogExpert.Core/Classes/Attributes/JsonColumnizerPropertyAttribute.cs index af1219fc..46f5267e 100644 --- a/src/LogExpert.Core/Classes/Persister/JsonColumnizerPropertyAttribute.cs +++ b/src/LogExpert.Core/Classes/Attributes/JsonColumnizerPropertyAttribute.cs @@ -1,10 +1,10 @@ -namespace LogExpert.Core.Classes.Persister; +namespace LogExpert.Core.Classes.Attributes; /// /// Marks a property for inclusion in columnizer JSON serialization. /// [AttributeUsage(AttributeTargets.Property)] -public class JsonColumnizerPropertyAttribute : Attribute +public sealed class JsonColumnizerPropertyAttribute : Attribute { } diff --git a/src/LogExpert.Core/Classes/Filter/FilterParams.cs b/src/LogExpert.Core/Classes/Filter/FilterParams.cs index dde05dfe..68376518 100644 --- a/src/LogExpert.Core/Classes/Filter/FilterParams.cs +++ b/src/LogExpert.Core/Classes/Filter/FilterParams.cs @@ -3,7 +3,7 @@ using System.Drawing; using System.Text.RegularExpressions; -using LogExpert.Core.Classes.Persister; +using LogExpert.Core.Classes.JsonConverters; using Newtonsoft.Json; diff --git a/src/LogExpert.Core/Classes/Persister/ColumnizerJsonConverter.cs b/src/LogExpert.Core/Classes/JsonConverters/ColumnizerJsonConverter.cs similarity index 97% rename from src/LogExpert.Core/Classes/Persister/ColumnizerJsonConverter.cs rename to src/LogExpert.Core/Classes/JsonConverters/ColumnizerJsonConverter.cs index 7f9927c4..344fe37d 100644 --- a/src/LogExpert.Core/Classes/Persister/ColumnizerJsonConverter.cs +++ b/src/LogExpert.Core/Classes/JsonConverters/ColumnizerJsonConverter.cs @@ -1,9 +1,11 @@ using System.Reflection; +using LogExpert.Core.Classes.Attributes; + using Newtonsoft.Json; using Newtonsoft.Json.Linq; -namespace LogExpert.Core.Classes.Persister; +namespace LogExpert.Core.Classes.JsonConverters; /// /// Custom JsonConverter for ILogLineColumnizer implementations. diff --git a/src/LogExpert.Core/Classes/Persister/EncodingJsonConverter.cs b/src/LogExpert.Core/Classes/JsonConverters/EncodingJsonConverter.cs similarity index 54% rename from src/LogExpert.Core/Classes/Persister/EncodingJsonConverter.cs rename to src/LogExpert.Core/Classes/JsonConverters/EncodingJsonConverter.cs index 95dcc29d..764785b5 100644 --- a/src/LogExpert.Core/Classes/Persister/EncodingJsonConverter.cs +++ b/src/LogExpert.Core/Classes/JsonConverters/EncodingJsonConverter.cs @@ -2,7 +2,7 @@ using Newtonsoft.Json; -namespace LogExpert.Core.Classes.Persister; +namespace LogExpert.Core.Classes.JsonConverters; /// /// Custom JsonConverter for Encoding objects. @@ -15,9 +15,16 @@ public override bool CanConvert (Type objectType) return typeof(Encoding).IsAssignableFrom(objectType); } + /// + /// Serializes the Encoding object to its name. + /// + /// + /// + /// public override void WriteJson (JsonWriter writer, object? value, JsonSerializer serializer) { ArgumentNullException.ThrowIfNull(writer); + if (value is not Encoding encoding) { writer.WriteNull(); @@ -27,9 +34,19 @@ public override void WriteJson (JsonWriter writer, object? value, JsonSerializer writer.WriteValue(encoding.WebName); } + /// + /// Reads a JSON value and converts it to an object. + /// + /// The to read from. Cannot be . + /// The type of the object to deserialize. This parameter is not used in this method. + /// The existing value of the object being deserialized. This parameter is not used in this method. + /// The calling serializer. This parameter is not used in this method. + /// An object corresponding to the JSON value. Returns if the + /// JSON value is , empty, or an invalid encoding name. public override object? ReadJson (JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) { ArgumentNullException.ThrowIfNull(reader); + if (reader.TokenType == JsonToken.Null) { return null; diff --git a/src/LogExpert.Core/Classes/Persister/IPersister.cs b/src/LogExpert.Core/Classes/Persister/IPersister.cs new file mode 100644 index 00000000..d9e0072c --- /dev/null +++ b/src/LogExpert.Core/Classes/Persister/IPersister.cs @@ -0,0 +1,30 @@ +using LogExpert.Core.Config; + +namespace LogExpert.Core.Classes.Persister; + +internal interface IPersister +{ + + string SavePersistenceData (string logFileName, PersistenceData persistenceData, Preferences preferences); + + string SavePersistenceDataWithFixedName (string persistenceFileName, PersistenceData persistenceData); + + PersistenceData LoadPersistenceData (string logFileName, Preferences preferences); + + PersistenceData LoadPersistenceDataOptionsOnly (string logFileName, Preferences preferences); + + PersistenceData LoadPersistenceDataOptionsOnlyFromFixedFile (string persistenceFile); + + PersistenceData LoadPersistenceDataFromFixedFile (string persistenceFile); + + PersistenceData Load (string fileName); + + //string BuildPersisterFileName (string logFileName, Preferences preferences); + + //string BuildSessionFileNameFromPath (string logFileName); + + //void Save (string fileName, PersistenceData persistenceData); + + //PersistenceData LoadInternal (string fileName); + +} diff --git a/src/LogExpert.Core/Classes/Persister/PersistenceData.cs b/src/LogExpert.Core/Classes/Persister/PersistenceData.cs index ca061d31..b771423a 100644 --- a/src/LogExpert.Core/Classes/Persister/PersistenceData.cs +++ b/src/LogExpert.Core/Classes/Persister/PersistenceData.cs @@ -1,6 +1,5 @@ -using System.Text; - using LogExpert.Core.Classes.Filter; +using LogExpert.Core.Classes.JsonConverters; using LogExpert.Core.Entities; using Newtonsoft.Json; @@ -21,7 +20,7 @@ public class PersistenceData public int CurrentLine { get; set; } = -1; [JsonConverter(typeof(EncodingJsonConverter))] - public Encoding Encoding { get; set; } + public System.Text.Encoding Encoding { get; set; } public string FileName { get; set; } diff --git a/src/LogExpert.Core/Classes/Persister/Persister.cs b/src/LogExpert.Core/Classes/Persister/Persister.cs index c6a13d06..25011252 100644 --- a/src/LogExpert.Core/Classes/Persister/Persister.cs +++ b/src/LogExpert.Core/Classes/Persister/Persister.cs @@ -1,5 +1,6 @@ using System.Text; +using LogExpert.Core.Classes.JsonConverters; using LogExpert.Core.Config; using Newtonsoft.Json; diff --git a/src/LogExpert.Core/Classes/Persister/PersisterXML.cs b/src/LogExpert.Core/Classes/Persister/PersisterXML.cs index 5eea6e96..832a8d6a 100644 --- a/src/LogExpert.Core/Classes/Persister/PersisterXML.cs +++ b/src/LogExpert.Core/Classes/Persister/PersisterXML.cs @@ -10,23 +10,61 @@ namespace LogExpert.Core.Classes.Persister; +/// +/// Persister for XML format persistence data. +/// public static class PersisterXML { #region Fields private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); - #endregion - - #region Private Methods - + /// + /// Reads all filter tab definitions from the given . + /// + /// + /// The root file XmlElement which may contain a direct child element named filterTabs. + /// Must not be null. + /// + /// + /// A list of instances. Returns an empty list if no filterTabs element exists. + /// + /// + /// Expected XML structure: + /// + /// + /// ... (persistence related child nodes) + /// + /// + /// + /// BASE64(JSON FilterParams) + /// + /// + /// + /// + /// + /// ]]> + /// Processing steps: + /// - Locates the filterTabs node under . + /// - Iterates each child node (expected: filterTab). + /// - For each node: + /// - Calls to hydrate a nested . + /// - Locates tabFilter and deserializes its first (and historically only) entry. + /// - Wraps both into a and adds it to the result list. + /// - If JSON deserialization of filter parameters fails, the error is logged and the specific tab is skipped. + /// Notes: + /// - Only the first entry of the deserialized filter list is used because the persisted format supports + /// exactly one filter per tab. + /// - Returns an empty list if the filterTabs node is absent. + /// private static List ReadFilterTabs (XmlElement startNode) { List dataList = []; XmlNode filterTabsNode = startNode.SelectSingleNode("filterTabs"); if (filterTabsNode != null) { - XmlNodeList filterTabNodeList = filterTabsNode.ChildNodes; // all "filterTab" nodes + XmlNodeList filterTabNodeList = filterTabsNode.ChildNodes; foreach (XmlNode node in filterTabNodeList) { @@ -39,7 +77,7 @@ private static List ReadFilterTabs (XmlElement startNode) FilterTabData data = new() { PersistenceData = persistenceData, - FilterParams = filterList[0] // there's only 1 + FilterParams = filterList[0] }; dataList.Add(data); @@ -50,13 +88,57 @@ private static List ReadFilterTabs (XmlElement startNode) return dataList; } + /// + /// Reads and deserializes all entries from a given XML element which contains + /// a child element named filters. + /// + /// + /// The XML element expected to have a child element filters. This method is used both for + /// global filter lists (root file element) and per-tab filters (tabFilter element). + /// Structure example: + /// + /// + /// + /// BASE64(JSON FilterParams) + /// + /// + /// BASE64(JSON FilterParams) + /// + /// + /// + /// ]]> + /// + /// + /// A list of deserialized instances. Returns an empty list if the + /// filters element is missing or no valid filter entries are found. + /// + /// + /// Processing steps: + /// 1. Locates the filters child node. + /// 2. Iterates each filter node. + /// 3. For each params child: + /// - Decodes its Base64 inner text to bytes. + /// - Deserializes JSON to . + /// - Calls to finalize state. + /// 4. Adds successfully deserialized instances to the result list. + /// Errors: + /// - during deserialization is logged (entry skipped). + /// - Possible from invalid Base64 is not caught here. + /// private static List ReadFilter (XmlElement startNode) { List filterList = []; + + if (startNode == null) + { + return filterList; + } + XmlNode filtersNode = startNode.SelectSingleNode("filters"); if (filtersNode != null) { - XmlNodeList filterNodeList = filtersNode.ChildNodes; // all "filter" nodes + XmlNodeList filterNodeList = filtersNode.ChildNodes; foreach (XmlNode node in filterNodeList) { foreach (XmlNode subNode in node.ChildNodes) @@ -65,8 +147,7 @@ private static List ReadFilter (XmlElement startNode) { var base64Text = subNode.InnerText; var data = Convert.FromBase64String(base64Text); - MemoryStream stream = new(data); - + using MemoryStream stream = new(data); try { FilterParams filterParams = JsonSerializer.Deserialize(stream); @@ -85,6 +166,20 @@ private static List ReadFilter (XmlElement startNode) return filterList; } + /// + /// Loads persistence data from an XML file (internal implementation without exception filtering). + /// + /// Full path to the XML persistence file to read. + /// + /// A populated instance. If the expected root node + /// (logexpert/file) is missing an empty instance with default values is returned. + /// + /// + /// This method:

+ /// 1. Loads the XML document.

+ /// 2. Selects the node logexpert/file.

+ /// 3. Delegates hydration to .

+ ///
private static PersistenceData LoadInternal (string fileName) { XmlDocument xmlDoc = new(); @@ -99,6 +194,31 @@ private static PersistenceData LoadInternal (string fileName) return persistenceData; } + /// + /// Reads persistence-related information (bookmarks, row heights, filters, encoding, options, etc.) + /// from a given assumed to represent a file element. + /// + /// + /// The XML node (ideally an ) containing child elements for bookmarks, + /// options, filters, filter tabs, and encoding. Must not be null; if the cast to + /// fails an empty with default values is returned. + /// + /// + /// A fully populated instance. Collections are initialized to empty lists + /// when corresponding XML sections are absent. + /// + /// + /// Processing order:

+ /// 1. Cast node to .

+ /// 2. Bookmarks via .

+ /// 3. Row heights via .

+ /// 4. Options via .

+ /// 5. File attributes: fileName, lineCount.

+ /// 6. Filters via .

+ /// 7. Filter tabs via .

+ /// 8. Encoding via .

+ /// Invalid integers for lineCount will throw . + ///
private static PersistenceData ReadPersistenceDataFromNode (XmlNode node) { PersistenceData persistenceData = new(); @@ -119,6 +239,28 @@ private static PersistenceData ReadPersistenceDataFromNode (XmlNode node) return persistenceData; } + /// + /// Attempts to resolve the file text from the given XML element. + /// + /// + /// The root file element which may contain an encoding child element: + /// + /// ]]> + /// The element must not be null (no internal null check performed). + /// + /// + /// The resolved when the encoding element exists and its name attribute + /// maps to a supported encoding; null if the encoding element is absent or the attribute is missing. + /// If the specified name is invalid or not supported an error is logged and is returned. + /// + /// + /// Processing rules: + /// - Looks for a direct child element named encoding. + /// - Reads its name attribute and calls . + /// - Catches and ; logs and falls back to . + /// - Does not throw for missing node/attribute; returns null in that case. + /// private static Encoding ReadEncoding (XmlElement fileElement) { XmlNode encodingNode = fileElement.SelectSingleNode("encoding"); @@ -144,13 +286,46 @@ private static Encoding ReadEncoding (XmlElement fileElement) return null; } + /// + /// Reads bookmark entries from the given XML element and returns them as a sorted list keyed by line number. + /// + /// + /// Expected XML structure: + /// + /// + /// + /// User set bookmark + /// 10 + /// 25 + /// + /// + /// 4 + /// 12 + /// + /// + /// + /// Processing details: + /// - Each bookmark element must have a line attribute that parses to an integer. + /// - Optional child element text provides the bookmark text/comment. + /// - Required child elements posX and posY define the overlay offset (parsed as integers). + /// - Invalid bookmark nodes (missing required data) are skipped and an error is logged. + /// - Bookmarks are stored in a keyed by their line number. + /// + /// The XML element that contains (or has as descendant) the bookmarks element. + /// + /// A sorted list of instances keyed by line number. Returns an empty list if no + /// bookmarks element exists. + /// + /// + /// Thrown if a numeric value (line / posX / posY) cannot be parsed to an integer. This will abort processing of the current bookmark. + /// private static SortedList ReadBookmarks (XmlElement startNode) { SortedList bookmarkList = []; - XmlNode boomarksNode = startNode.SelectSingleNode("bookmarks"); - if (boomarksNode != null) + XmlNode bookmarksNode = startNode.SelectSingleNode("bookmarks"); + if (bookmarksNode != null) { - XmlNodeList bookmarkNodeList = boomarksNode.ChildNodes; // all "bookmark" nodes + XmlNodeList bookmarkNodeList = bookmarksNode.ChildNodes; foreach (XmlNode node in bookmarkNodeList) { string text = null; @@ -207,13 +382,34 @@ private static Encoding ReadEncoding (XmlElement fileElement) return bookmarkList; } + /// + /// Reads row height entries from the given and returns them + /// as a sorted list keyed by line number. + /// + /// + /// Expected XML structure: + /// + /// + /// + /// + /// + /// + /// Each rowheight element must contain a line attribute (the line number) + /// and a height attribute (the row height value). + /// Missing or invalid attributes will throw a during parsing. + /// + /// The XML element to search within (usually the file element). + /// + /// A mapping line numbers to instances. + /// Returns an empty list if no rowheights node is present. + /// private static SortedList ReadRowHeightList (XmlElement startNode) { SortedList rowHeightList = []; XmlNode rowHeightsNode = startNode.SelectSingleNode("rowheights"); if (rowHeightsNode != null) { - XmlNodeList rowHeightNodeList = rowHeightsNode.ChildNodes; // all "rowheight" nodes + XmlNodeList rowHeightNodeList = rowHeightsNode.ChildNodes; foreach (XmlNode node in rowHeightNodeList) { string height = null; @@ -239,6 +435,15 @@ private static SortedList ReadRowHeightList (XmlElement sta return rowHeightList; } + /// + /// Reads configuration options from the specified XML element and populates the provided object with the extracted settings. + /// + /// This method processes various configuration options such as multi-file settings, current line + /// information, filter visibility, and more. It expects the XML structure to contain specific nodes and attributes + /// that define these settings. If certain attributes are missing or invalid, default values are applied. + /// The XML element containing the configuration options to be read. + /// The object to populate with the settings extracted from the XML element. private static void ReadOptions (XmlElement startNode, PersistenceData persistenceData) { XmlNode optionsNode = startNode.SelectSingleNode("options"); @@ -250,7 +455,9 @@ private static void ReadOptions (XmlElement startNode, PersistenceData persisten { persistenceData.MultiFileMaxDays = value != null ? short.Parse(value) : 0; } - catch (Exception) + catch (Exception ex) when (ex is ArgumentNullException or + FormatException or + OverflowException) { persistenceData.MultiFileMaxDays = 0; } @@ -258,7 +465,7 @@ private static void ReadOptions (XmlElement startNode, PersistenceData persisten XmlNode multiFileNode = optionsNode.SelectSingleNode("multifile"); if (multiFileNode != null) { - XmlNodeList multiFileNodeList = multiFileNode.ChildNodes; // all "fileEntry" nodes + XmlNodeList multiFileNodeList = multiFileNode.ChildNodes; foreach (XmlNode node in multiFileNodeList) { string fileName = null; @@ -332,6 +539,27 @@ private static void ReadOptions (XmlElement startNode, PersistenceData persisten } } + /// + /// Retrieves the value of a specified attribute from a child element within the given . + /// + /// + /// The parent XML node expected to contain the child element identified by . + /// Must not be null; otherwise a will occur before this method is called. + /// + /// + /// The name of the child element to search for (e.g. "multifile", "filter", "bookmarklist"). + /// + /// + /// The name of the attribute whose value should be returned (e.g. "enabled", "pattern", "visible"). + /// + /// + /// The attribute value as a string if the child element exists and is an and the attribute is present; + /// otherwise null. + /// + /// + /// This method performs a direct XPath child lookup using . + /// It does not perform any conversion of the returned value. Callers are responsible for parsing or validating the result. + /// private static string GetOptionsAttribute (XmlNode optionsNode, string elementName, string attrName) { XmlNode node = optionsNode.SelectSingleNode(elementName); @@ -340,10 +568,10 @@ private static string GetOptionsAttribute (XmlNode optionsNode, string elementNa return null; } - if (node is XmlElement) + if (node is XmlElement element) { - var value = (node as XmlElement).GetAttribute(attrName); - return value; + var valueAttr = element.GetAttribute(attrName); + return valueAttr; } else { @@ -351,9 +579,31 @@ private static string GetOptionsAttribute (XmlNode optionsNode, string elementNa } } + /// + /// Loads persistence data from the specified XML file. + /// + /// Full path to the persistence XML file. + /// + /// A populated instance if loading succeeds; otherwise null + /// when the file cannot be read or parsed (XML/IO/security related issues are logged). + /// + /// + /// Only XML format is attempted. Any , , + /// or is caught and logged; in these cases null is returned. + /// public static PersistenceData Load (string fileName) { - return LoadInternal(fileName); + try + { + return LoadInternal(fileName); + } + catch (Exception xmlParsingException) when (xmlParsingException is XmlException or + UnauthorizedAccessException or + IOException) + { + _logger.Error(xmlParsingException, $"Error loading persistence data from {fileName}, unknown format, parsing xml or json was not possible"); + return null; + } } #endregion diff --git a/src/LogExpert.Core/Classes/Persister/ProjectPersister.cs b/src/LogExpert.Core/Classes/Persister/ProjectPersister.cs index 5e9df3f7..b2ae8f17 100644 --- a/src/LogExpert.Core/Classes/Persister/ProjectPersister.cs +++ b/src/LogExpert.Core/Classes/Persister/ProjectPersister.cs @@ -33,23 +33,19 @@ public static ProjectData LoadProjectData (string projectFileName) IOException or JsonSerializationException) { - //Backup try to load xml instead of json - var xmlData = ProjectPersisterXML.LoadProjectData(projectFileName); - if (xmlData != null) - { - return xmlData; - } - _logger.Error(ex, $"Error loading persistence data from {projectFileName}"); - return new ProjectData(); + _logger.Warn($"Error loading persistence data from {projectFileName}, trying old xml version"); + return ProjectPersisterXML.LoadProjectData(projectFileName); } } /// - /// Saves the project session data to a specified file. + /// Saves the specified project data to a file in JSON format. /// - /// - /// + /// The method serializes the into a JSON string with indented + /// formatting and writes it to the specified using UTF-8 encoding. + /// The path to the file where the project data will be saved. Cannot be null or empty. + /// The project data to be serialized and saved. Cannot be null. public static void SaveProjectData (string projectFileName, ProjectData projectData) { var settings = new JsonSerializerSettings diff --git a/src/LogExpert.Core/Classes/Persister/ProjectPersisterXML.cs b/src/LogExpert.Core/Classes/Persister/ProjectPersisterXML.cs index 2803a883..618b925e 100644 --- a/src/LogExpert.Core/Classes/Persister/ProjectPersisterXML.cs +++ b/src/LogExpert.Core/Classes/Persister/ProjectPersisterXML.cs @@ -1,33 +1,53 @@ using System.Xml; +using NLog; + namespace LogExpert.Core.Classes.Persister; public static class ProjectPersisterXML { - #region Public methods + private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); + /// + /// Loads project data from the specified XML file. + /// + /// The method reads the XML file to extract file names and layout information. If the XML file + /// contains a layout element, its inner XML is stored in the TabLayoutXml property of the returned + /// object. If any exception occurs during the loading process, an error is logged, and an + /// empty object is returned. + /// The path to the XML file containing the project data. + /// A object populated with file names and layout information from the XML file. If an + /// error occurs during loading, an empty object is returned. public static ProjectData LoadProjectData (string projectFileName) { var projectData = new ProjectData(); var xmlDoc = new XmlDocument(); - xmlDoc.Load(projectFileName); - var fileList = xmlDoc.GetElementsByTagName("member"); - - foreach (XmlNode fileNode in fileList) + try { - var fileElement = fileNode as XmlElement; - var fileName = fileElement.GetAttribute("fileName"); - projectData.FileNames.Add(fileName); - } + xmlDoc.Load(projectFileName); + var fileList = xmlDoc.GetElementsByTagName("member"); + + foreach (XmlNode fileNode in fileList) + { + var fileElement = fileNode as XmlElement; + var fileName = fileElement.GetAttribute("fileName"); + projectData.FileNames.Add(fileName); + } - var layoutElements = xmlDoc.GetElementsByTagName("layout"); - if (layoutElements.Count > 0) + var layoutElements = xmlDoc.GetElementsByTagName("layout"); + if (layoutElements.Count > 0) + { + projectData.TabLayoutXml = layoutElements[0].InnerXml; + } + + return projectData; + } + catch (Exception xmlParsingException) when (xmlParsingException is XmlException or + UnauthorizedAccessException or + IOException) { - projectData.TabLayoutXml = layoutElements[0].InnerXml; + _logger.Error(xmlParsingException, $"Error loading persistence data from {projectFileName}, unknown format, parsing xml or json was not possible"); + return new ProjectData(); } - - return projectData; } - - #endregion } \ No newline at end of file diff --git a/src/LogExpert.Core/Config/LoadResult.cs b/src/LogExpert.Core/Config/LoadResult.cs index 8b5b0843..cbba918e 100644 --- a/src/LogExpert.Core/Config/LoadResult.cs +++ b/src/LogExpert.Core/Config/LoadResult.cs @@ -1,5 +1,3 @@ -using LogExpert.Core.Entities; - namespace LogExpert.Core.Config; /// @@ -7,29 +5,79 @@ namespace LogExpert.Core.Config; /// public class LoadResult { + /// + /// Gets or sets the loaded settings. + /// public Settings Settings { get; set; } + + /// + /// Indicates whether the settings were loaded from a backup. + /// public bool LoadedFromBackup { get; set; } + + /// + /// Message to show to the user if settings were recovered from backup + /// public string RecoveryMessage { get; set; } + + /// + /// Gets or sets the title used for recovery operations. + /// public string RecoveryTitle { get; set; } + + /// + /// Indicates whether a critical failure occurred during loading. + /// public bool CriticalFailure { get; set; } + + /// + /// Message to show to the user in case of a critical failure + /// public string CriticalMessage { get; set; } + + /// + /// Gets or sets the title used to indicate critical messages. + /// public string CriticalTitle { get; set; } + + /// + /// Gets or sets a value indicating whether a user choice is required. + /// public bool RequiresUserChoice { get; set; } - - public static LoadResult Success(Settings settings) => new() + + /// + /// Creates a successful LoadResult. + /// + /// + /// + public static LoadResult Success (Settings settings) => new() { Settings = settings }; - - public static LoadResult FromBackup(Settings settings, string message, string title) => new() + + /// + /// Creates a LoadResult indicating settings were loaded from a backup. + /// + /// + /// + /// + /// + public static LoadResult FromBackup (Settings settings, string message, string title) => new() { Settings = settings, LoadedFromBackup = true, RecoveryMessage = message, RecoveryTitle = title }; - - public static LoadResult Critical(Settings settings, string title, string message) => new() + + /// + /// Creates a LoadResult indicating a critical failure occurred. + /// + /// + /// + /// + /// + public static LoadResult Critical (Settings settings, string title, string message) => new() { Settings = settings, CriticalFailure = true, diff --git a/src/LogExpert.Tests/ColumnizerJsonConverterTests.cs b/src/LogExpert.Tests/ColumnizerJsonConverterTests.cs index 0a269035..436b1dd2 100644 --- a/src/LogExpert.Tests/ColumnizerJsonConverterTests.cs +++ b/src/LogExpert.Tests/ColumnizerJsonConverterTests.cs @@ -1,4 +1,5 @@ -using LogExpert.Core.Classes.Persister; +using LogExpert.Core.Classes.Attributes; +using LogExpert.Core.Classes.JsonConverters; using Newtonsoft.Json; diff --git a/src/LogExpert.Tests/ConfigManagerTest.cs b/src/LogExpert.Tests/ConfigManagerTest.cs index 8ce0a536..fe9f6b32 100644 --- a/src/LogExpert.Tests/ConfigManagerTest.cs +++ b/src/LogExpert.Tests/ConfigManagerTest.cs @@ -40,9 +40,13 @@ public void TearDown () { Directory.Delete(_testDir, recursive: true); } - catch + catch (IOException) { - // Ignore cleanup errors + // Ignore IO errors during cleanup + } + catch (UnauthorizedAccessException) + { + // Ignore access errors during cleanup } } } @@ -56,12 +60,9 @@ private T InvokePrivateStaticMethod (string methodName, params object[] param { MethodInfo? method = typeof(ConfigManager).GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Static); - if (method == null) - { - throw new Exception($"Static method {methodName} not found"); - } - - return (T)method.Invoke(null, parameters); + return method == null + ? throw new Exception($"Static method {methodName} not found") + : (T)method.Invoke(null, parameters); } /// @@ -71,12 +72,9 @@ private T InvokePrivateInstanceMethod (string methodName, params object[] par { MethodInfo? method = typeof(ConfigManager).GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance); - if (method == null) - { - throw new Exception($"Instance method {methodName} not found"); - } - - return (T)method.Invoke(ConfigManager.Instance, parameters); + return method == null + ? throw new Exception($"Instance method {methodName} not found") + : (T)method.Invoke(ConfigManager.Instance, parameters); } /// @@ -84,12 +82,8 @@ private T InvokePrivateInstanceMethod (string methodName, params object[] par /// private void InvokePrivateInstanceMethod (string methodName, params object[] parameters) { - MethodInfo? method = typeof(ConfigManager).GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance); - - if (method == null) - { - throw new Exception($"Instance method {methodName} not found"); - } + MethodInfo? method = typeof(ConfigManager).GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new Exception($"Instance method {methodName} not found"); method.Invoke(ConfigManager.Instance, parameters); } @@ -99,8 +93,11 @@ private void InvokePrivateInstanceMethod (string methodName, params object[] par /// private Settings CreateTestSettings () { - var settings = new Settings(); - settings.Preferences = new Preferences(); + var settings = new Settings + { + Preferences = new Preferences() + }; + return settings; } @@ -293,11 +290,11 @@ public void SaveAsJSON_CreatesBackupFile () Assert.That(File.Exists(backupFile), Is.True, "Backup file should exist"); string backupContent = File.ReadAllText(backupFile); - Settings? backupSettings = JsonConvert.DeserializeObject(backupContent); + Settings backupSettings = JsonConvert.DeserializeObject(backupContent); Assert.That(backupSettings.AlwaysOnTop, Is.True, "Backup should contain previous settings"); string mainContent = File.ReadAllText(_testSettingsFile.FullName); - Settings? mainSettings = JsonConvert.DeserializeObject(mainContent); + Settings mainSettings = JsonConvert.DeserializeObject(mainContent); Assert.That(mainSettings.AlwaysOnTop, Is.False, "Main file should contain new settings"); } diff --git a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs index 0918752b..f1007e94 100644 --- a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs +++ b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs @@ -2765,6 +2765,8 @@ private void OnSaveProjectToolStripMenuItemClick (object sender, EventArgs e) FileNames = fileNames, TabLayoutXml = SaveLayout() }; + + ProjectPersister.SaveProjectData(fileName, projectData); } } diff --git a/src/LogExpert.UI/Dialogs/SettingsDialog.cs b/src/LogExpert.UI/Dialogs/SettingsDialog.cs index b7c928fa..5d88f500 100644 --- a/src/LogExpert.UI/Dialogs/SettingsDialog.cs +++ b/src/LogExpert.UI/Dialogs/SettingsDialog.cs @@ -13,7 +13,7 @@ namespace LogExpert.Dialogs; -//TODO: This class should not knoow ConfigManager? +//TODO: This class should not know ConfigManager? [SupportedOSPlatform("windows")] internal partial class SettingsDialog : Form { @@ -1005,7 +1005,7 @@ private void OnBtnExportClick (object sender, EventArgs e) } /// - /// + /// Import settings from file /// /// /// diff --git a/src/LogExpert/Config/ConfigManager.cs b/src/LogExpert/Config/ConfigManager.cs index 769ea11f..f9ea307f 100644 --- a/src/LogExpert/Config/ConfigManager.cs +++ b/src/LogExpert/Config/ConfigManager.cs @@ -2,11 +2,11 @@ using System.Globalization; using System.Reflection; using System.Security; -using System.Text; using System.Windows.Forms; using LogExpert.Core.Classes; -using LogExpert.Core.Classes.Persister; +using LogExpert.Core.Classes.Filter; +using LogExpert.Core.Classes.JsonConverters; using LogExpert.Core.Config; using LogExpert.Core.Entities; using LogExpert.Core.EventArguments; @@ -187,7 +187,6 @@ public ImportResult Import (FileInfo fileInfo, ExportImportFlags importFlags) return ImportResult.RequiresConfirmation("Confirm Import", confirmationMessage); } - // Log what we're importing _logger.Info($"Importing: Filters={importedSettings.FilterList?.Count ?? 0}, " + $"History={importedSettings.FileHistoryList?.Count ?? 0}, " + $"Highlights={importedSettings.Preferences?.HighlightGroupList?.Count ?? 0}"); @@ -223,18 +222,18 @@ public void ImportHighlightSettings (FileInfo fileInfo, ExportImportFlags import /// private Settings Load () { - _logger.Info(CultureInfo.InvariantCulture, "Loading settings"); + _logger.Info($"### {nameof(Load)}: Loading settings"); string dir; if (!File.Exists(Path.Combine(PortableModeDir, PortableModeSettingsFileName))) { - _logger.Info(CultureInfo.InvariantCulture, "Load settings standard mode"); + _logger.Info($"### {nameof(Load)}: Load settings standard mode"); dir = ConfigDir; } else { - _logger.Info("Load settings portable mode"); + _logger.Info($"### {nameof(Load)}: Load settings portable mode"); dir = Application.StartupPath; } @@ -276,45 +275,19 @@ private Settings Load () // Handle recovery notifications (if loaded from backup) if (result.LoadedFromBackup) { - _logger.Info("Settings recovered from backup, notification pending"); - // Store recovery information for UI layer to display - // Note: MessageBox should be shown by UI layer after initialization - OnSettingsRecoveredFromBackup(result.RecoveryTitle, result.RecoveryMessage); + _logger.Info($"### {nameof(Load)}: Settings recovered from backup"); } // Handle critical failures if (result.CriticalFailure) { - _logger.Error("Critical settings load failure, user decision required"); - // Store critical error for UI layer to display - // Note: MessageBox should be shown by UI layer after initialization - OnCriticalSettingsFailure(result.CriticalTitle, result.CriticalMessage); + _logger.Error($"### {nameof(Load)}: settings load failure. Set to default settings"); + result = LoadOrCreateNew(null); } return result.Settings; } - /// - /// Event raised when settings are recovered from backup (UI layer should show notification) - /// - private void OnSettingsRecoveredFromBackup (string title, string message) - { - // UI layer should subscribe to this or check status after ConfigManager initialization - // For now, just log - proper event mechanism can be added later - _logger.Warn($"Recovery notification: {title} - {message}"); - } - - /// - /// Event raised when critical settings failure occurs (UI layer should show error and get user choice) - /// - private void OnCriticalSettingsFailure (string title, string message) - { - // UI layer should subscribe to this or check status after ConfigManager initialization - // For now, default to creating new settings - proper event mechanism can be added later - _logger.Error($"Critical failure: {title} - {message}"); - _logger.Warn("Defaulting to create new settings (UI layer should prompt user)"); - } - /// /// Loads Settings of a given file or creates new settings if the file does not exist. /// Includes automatic backup recovery if main file is corrupted. @@ -529,7 +502,7 @@ private static Settings InitializeSettings (Settings settings) settings.FilterRangeHistoryList ??= []; - foreach (Core.Classes.Filter.FilterParams filterParams in settings.FilterList) + foreach (FilterParams filterParams in settings.FilterList) { filterParams.Init(); } @@ -548,7 +521,7 @@ private static Settings InitializeSettings (Settings settings) settings.Preferences.MultiFileOptions ??= new MultiFileOptions(); - settings.Preferences.DefaultEncoding ??= Encoding.Default.HeaderName; + settings.Preferences.DefaultEncoding ??= System.Text.Encoding.Default.HeaderName; if (settings.Preferences.MaximumFilterEntriesDisplayed == 0) { @@ -639,8 +612,8 @@ private static void WriteSettingsFile (FileInfo fileInfo, string json) try { - _logger.Debug($"Writing to {fileInfo.FullName}"); - File.WriteAllText(tempFile, json, Encoding.UTF8); + _logger.Info($"Writing to {fileInfo.FullName}"); + File.WriteAllText(tempFile, json, System.Text.Encoding.UTF8); if (File.Exists(fileInfo.FullName)) { @@ -648,7 +621,7 @@ private static void WriteSettingsFile (FileInfo fileInfo, string json) if (existingSize > 0) { File.Copy(fileInfo.FullName, backupFile, overwrite: true); - _logger.Debug($"Created backup: {backupFile} ({existingSize} bytes)"); + _logger.Info($"Created backup: {backupFile} ({existingSize} bytes)"); } else { @@ -657,7 +630,6 @@ private static void WriteSettingsFile (FileInfo fileInfo, string json) } File.Move(tempFile, fileInfo.FullName, overwrite: true); - _logger.Info("Settings saved successfully"); } catch (Exception ex) { @@ -699,7 +671,6 @@ PathTooLongException or try { File.Delete(tempFile); - _logger.Debug($"Cleaned up temp file: {tempFile}"); } catch (Exception cleanupException) when (cleanupException is ArgumentException or DirectoryNotFoundException or diff --git a/src/LogExpert/Program.cs b/src/LogExpert/Program.cs index 47614183..838505e4 100644 --- a/src/LogExpert/Program.cs +++ b/src/LogExpert/Program.cs @@ -64,27 +64,32 @@ private static void Main (string[] args) if (cfgFileInfo.Exists) { ImportResult importResult = ConfigManager.Instance.Import(cfgFileInfo, ExportImportFlags.All); - + // Handle import result if (!importResult.Success) { - string message = importResult.RequiresUserConfirmation - ? importResult.ConfirmationMessage + string message = importResult.RequiresUserConfirmation + ? importResult.ConfirmationMessage : importResult.ErrorMessage; - string title = importResult.RequiresUserConfirmation - ? importResult.ConfirmationTitle + string title = importResult.RequiresUserConfirmation + ? importResult.ConfirmationTitle : importResult.ErrorTitle; - - MessageBox.Show(message, title, MessageBoxButtons.OK, MessageBoxIcon.Warning); + + if (MessageBox.Show(message, title, MessageBoxButtons.YesNo, MessageBoxIcon.Warning) == DialogResult.No) + { + _logger.Warn(CultureInfo.InvariantCulture, "### Program: Import of config file cancelled by user."); + Application.Exit(); + return; + } } } else { - MessageBox.Show(@"Config file not found", @"LogExpert"); + _ = MessageBox.Show(@"Config file not found", @"LogExpert"); } } - PluginRegistry.PluginRegistry.Instance.Create(ConfigManager.Instance.ConfigDir, ConfigManager.Instance.Settings.Preferences.PollingInterval); + _ = PluginRegistry.PluginRegistry.Instance.Create(ConfigManager.Instance.ConfigDir, ConfigManager.Instance.Settings.Preferences.PollingInterval); var pId = Process.GetCurrentProcess().SessionId; @@ -106,7 +111,7 @@ private static void Main (string[] args) LogExpertProxy proxy = new(logWin); LogExpertApplicationContext context = new(proxy, logWin); - Task.Run(() => RunServerLoopAsync(SendMessageToProxy, proxy, cts.Token)); + _ = Task.Run(() => RunServerLoopAsync(SendMessageToProxy, proxy, cts.Token)); Application.Run(context); } @@ -137,7 +142,7 @@ private static void Main (string[] args) if (counter == 0) { _logger.Error(errMsg, "IpcClientChannel error, giving up: "); - MessageBox.Show($"Cannot open connection to first instance ({errMsg})", "LogExpert"); + _ = MessageBox.Show($"Cannot open connection to first instance ({errMsg})", "LogExpert"); } //TODO: Remove this from here? Why is it called from the Main project and not from the main window? @@ -159,12 +164,12 @@ private static void Main (string[] args) { _logger.Error(ex, "Mutex error, giving up: "); cts.Cancel(); - MessageBox.Show($"Cannot open connection to first instance ({ex.Message})", "LogExpert"); + _ = MessageBox.Show($"Cannot open connection to first instance ({ex.Message})", "LogExpert"); } } catch (SecurityException se) { - MessageBox.Show("Insufficient system rights for LogExpert. Maybe you have started it from a network drive. Please start LogExpert from a local drive.\n(" + se.Message + ")", "LogExpert Error"); + _ = MessageBox.Show("Insufficient system rights for LogExpert. Maybe you have started it from a network drive. Please start LogExpert from a local drive.\n(" + se.Message + ")", "LogExpert Error"); cts.Cancel(); } } From 5b495a384e75fa95f751c5b5c10e700c34681d87 Mon Sep 17 00:00:00 2001 From: Hirogen Date: Tue, 4 Nov 2025 19:51:17 +0100 Subject: [PATCH 5/6] review comment --- src/LogExpert.Core/Config/LoadResult.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/LogExpert.Core/Config/LoadResult.cs b/src/LogExpert.Core/Config/LoadResult.cs index b346699c..f2cf515e 100644 --- a/src/LogExpert.Core/Config/LoadResult.cs +++ b/src/LogExpert.Core/Config/LoadResult.cs @@ -52,11 +52,8 @@ public class LoadResult /// /// public static LoadResult Success (Settings settings) => new() - { - Settings = settings - }; /// From 7d098508bdab837f181a3c0d1ecd295e9e60e42b Mon Sep 17 00:00:00 2001 From: Hirogen Date: Tue, 4 Nov 2025 19:57:44 +0100 Subject: [PATCH 6/6] review comments --- .../Classes/Persister/IPersister.cs | 30 ------------------- .../Dialogs/LogTabWindow/LogTabWindow.cs | 1 - src/LogExpert.UI/Dialogs/SettingsDialog.cs | 4 +-- src/LogExpert/Config/ConfigManager.cs | 2 +- 4 files changed, 3 insertions(+), 34 deletions(-) delete mode 100644 src/LogExpert.Core/Classes/Persister/IPersister.cs diff --git a/src/LogExpert.Core/Classes/Persister/IPersister.cs b/src/LogExpert.Core/Classes/Persister/IPersister.cs deleted file mode 100644 index d9e0072c..00000000 --- a/src/LogExpert.Core/Classes/Persister/IPersister.cs +++ /dev/null @@ -1,30 +0,0 @@ -using LogExpert.Core.Config; - -namespace LogExpert.Core.Classes.Persister; - -internal interface IPersister -{ - - string SavePersistenceData (string logFileName, PersistenceData persistenceData, Preferences preferences); - - string SavePersistenceDataWithFixedName (string persistenceFileName, PersistenceData persistenceData); - - PersistenceData LoadPersistenceData (string logFileName, Preferences preferences); - - PersistenceData LoadPersistenceDataOptionsOnly (string logFileName, Preferences preferences); - - PersistenceData LoadPersistenceDataOptionsOnlyFromFixedFile (string persistenceFile); - - PersistenceData LoadPersistenceDataFromFixedFile (string persistenceFile); - - PersistenceData Load (string fileName); - - //string BuildPersisterFileName (string logFileName, Preferences preferences); - - //string BuildSessionFileNameFromPath (string logFileName); - - //void Save (string fileName, PersistenceData persistenceData); - - //PersistenceData LoadInternal (string fileName); - -} diff --git a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs index f1007e94..c99a075e 100644 --- a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs +++ b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs @@ -2766,7 +2766,6 @@ private void OnSaveProjectToolStripMenuItemClick (object sender, EventArgs e) TabLayoutXml = SaveLayout() }; - ProjectPersister.SaveProjectData(fileName, projectData); } } diff --git a/src/LogExpert.UI/Dialogs/SettingsDialog.cs b/src/LogExpert.UI/Dialogs/SettingsDialog.cs index 5d88f500..def435ed 100644 --- a/src/LogExpert.UI/Dialogs/SettingsDialog.cs +++ b/src/LogExpert.UI/Dialogs/SettingsDialog.cs @@ -766,9 +766,9 @@ private void OnListBoxPluginSelectedIndexChanged (object sender, EventArgs e) if (selectedPlugin != null) { - if (selectedPlugin is ILogExpertPluginConfigurator) + if (selectedPlugin is ILogExpertPluginConfigurator pluginConfigurator) { - _selectedPlugin = selectedPlugin as ILogExpertPluginConfigurator; + _selectedPlugin = pluginConfigurator; if (_selectedPlugin.HasEmbeddedForm()) { diff --git a/src/LogExpert/Config/ConfigManager.cs b/src/LogExpert/Config/ConfigManager.cs index f9ea307f..8f67146f 100644 --- a/src/LogExpert/Config/ConfigManager.cs +++ b/src/LogExpert/Config/ConfigManager.cs @@ -299,7 +299,7 @@ private Settings Load () /// private LoadResult LoadOrCreateNew (FileInfo fileInfo) { - //TODO this needs to be refactor, its quite big + //TODO this needs to be refactord, its quite big lock (_loadSaveLock) { Settings settings = null;