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/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/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 7d202d35..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; @@ -285,8 +286,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..832a8d6a --- /dev/null +++ b/src/LogExpert.Core/Classes/Persister/PersisterXML.cs @@ -0,0 +1,610 @@ +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; + +/// +/// Persister for XML format persistence data. +/// +public static class PersisterXML +{ + #region Fields + + private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); + + /// + /// 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; + + 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] + }; + + dataList.Add(data); + } + } + } + + 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; + 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); + using 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; + } + + /// + /// 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(); + xmlDoc.Load(fileName); + XmlNode fileNode = xmlDoc.SelectSingleNode("logexpert/file"); + PersistenceData persistenceData = new(); + if (fileNode != null) + { + persistenceData = ReadPersistenceDataFromNode(fileNode); + } + + 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(); + 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; + } + + /// + /// 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"); + 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; + } + + /// + /// 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 bookmarksNode = startNode.SelectSingleNode("bookmarks"); + if (bookmarksNode != null) + { + XmlNodeList bookmarkNodeList = bookmarksNode.ChildNodes; + 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; + } + + /// + /// 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; + 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; + } + + /// + /// 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"); + 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 ex) when (ex is ArgumentNullException or + FormatException or + OverflowException) + { + persistenceData.MultiFileMaxDays = 0; + } + + XmlNode multiFileNode = optionsNode.SelectSingleNode("multifile"); + if (multiFileNode != null) + { + XmlNodeList multiFileNodeList = multiFileNode.ChildNodes; + 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"); + } + } + + /// + /// 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); + if (node == null) + { + return null; + } + + if (node is XmlElement element) + { + var valueAttr = element.GetAttribute(attrName); + return valueAttr; + } + else + { + return null; + } + } + + /// + /// 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) + { + 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 +} \ 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..b2ae8f17 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,22 @@ public static ProjectData LoadProjectData (string projectFileName) return JsonConvert.DeserializeObject(json, settings); } catch (Exception ex) when (ex is UnauthorizedAccessException or - IOException) + IOException or + JsonSerializationException) { - _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 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 new file mode 100644 index 00000000..618b925e --- /dev/null +++ b/src/LogExpert.Core/Classes/Persister/ProjectPersisterXML.cs @@ -0,0 +1,53 @@ +using System.Xml; + +using NLog; + +namespace LogExpert.Core.Classes.Persister; + +public static class ProjectPersisterXML +{ + 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(); + try + { + 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; + } + catch (Exception xmlParsingException) when (xmlParsingException is XmlException or + UnauthorizedAccessException or + IOException) + { + _logger.Error(xmlParsingException, $"Error loading persistence data from {projectFileName}, unknown format, parsing xml or json was not possible"); + return new ProjectData(); + } + } +} \ 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..ae1d56e3 --- /dev/null +++ b/src/LogExpert.Core/Config/ImportResult.cs @@ -0,0 +1,59 @@ +namespace LogExpert.Core.Config; + +/// +/// Result of a settings import operation +/// +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 }; + + 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..f2cf515e --- /dev/null +++ b/src/LogExpert.Core/Config/LoadResult.cs @@ -0,0 +1,89 @@ +namespace LogExpert.Core.Config; + +/// +/// Result of a settings load operation +/// +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; } + + /// + /// Creates a successful LoadResult. + /// + /// + /// + public static LoadResult Success (Settings settings) => new() + { + Settings = settings + }; + + /// + /// 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 + }; + + /// + /// Creates a LoadResult indicating a critical failure occurred. + /// + /// + /// + /// + /// + public static LoadResult Critical (Settings settings, string title, string message) => new() + { + Settings = settings, + CriticalFailure = true, + CriticalTitle = title, + CriticalMessage = message, + RequiresUserChoice = true + }; +} \ No newline at end of file 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/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 new file mode 100644 index 00000000..fe9f6b32 --- /dev/null +++ b/src/LogExpert.Tests/ConfigManagerTest.cs @@ -0,0 +1,655 @@ +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 (IOException) + { + // Ignore IO errors during cleanup + } + catch (UnauthorizedAccessException) + { + // Ignore access errors during cleanup + } + } + } + + #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); + + return method == null + ? throw new Exception($"Static method {methodName} not found") + : (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); + + return method == null + ? throw new Exception($"Instance method {methodName} not found") + : (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) + ?? 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 + { + 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.cs b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs index 0918752b..c99a075e 100644 --- a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs +++ b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs @@ -2765,6 +2765,7 @@ private void OnSaveProjectToolStripMenuItemClick (object sender, EventArgs e) FileNames = fileNames, TabLayoutXml = SaveLayout() }; + ProjectPersister.SaveProjectData(fileName, projectData); } } 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..def435ed 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 { @@ -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 pluginConfigurator) { + _selectedPlugin = pluginConfigurator; + 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; } @@ -1007,7 +1005,7 @@ private void OnBtnExportClick (object sender, EventArgs e) } /// - /// + /// Import settings from file /// /// /// @@ -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..8f67146f 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.Text; +using System.Security; using System.Windows.Forms; using LogExpert.Core.Classes; +using LogExpert.Core.Classes.Filter; +using LogExpert.Core.Classes.JsonConverters; 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,116 @@ 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); + } + + _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,20 +216,24 @@ 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"); + _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; } @@ -137,148 +242,300 @@ 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($"### {nameof(Load)}: Settings recovered from backup"); } - catch (Exception ex) when (ex is not OperationCanceledException) + + // Handle critical failures + if (result.CriticalFailure) { - _logger.Error($"Unexpected error: {ex.Message}"); + _logger.Error($"### {nameof(Load)}: settings load failure. Set to default settings"); + result = LoadOrCreateNew(null); } - return LoadOrCreateNew(null); - + return result.Settings; } /// - /// Loads Settings of a given file or creates new settings if the file does not exist + /// 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 refactord, 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 (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 ??= System.Text.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 +548,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 +585,104 @@ 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.Info($"Writing to {fileInfo.FullName}"); + File.WriteAllText(tempFile, json, System.Text.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.Info($"Created backup: {backupFile} ({existingSize} bytes)"); + } + else + { + _logger.Warn($"Existing settings file is empty ({existingSize} bytes), skipping backup"); + } + } + + File.Move(tempFile, fileInfo.FullName, overwrite: true); + } + 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); + } + 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 +692,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 +707,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 +743,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 +786,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..838505e4 100644 --- a/src/LogExpert/Program.cs +++ b/src/LogExpert/Program.cs @@ -63,15 +63,33 @@ 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; + + 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; @@ -93,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); } @@ -124,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? @@ -146,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(); } }