diff --git a/.editorconfig b/.editorconfig index 96594f0..88a14c3 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,57 +1,10 @@ -[*.cs] +[*.{cs,vb}] -# CS8602: Dereference of a possibly null reference. -dotnet_diagnostic.CS8602.severity = suggestion -csharp_indent_labels = one_less_than_current -csharp_using_directive_placement = outside_namespace:silent -csharp_prefer_simple_using_statement = true:suggestion -csharp_prefer_braces = true:silent -csharp_style_namespace_declarations = file_scoped:silent -csharp_style_prefer_method_group_conversion = true:silent -csharp_style_expression_bodied_methods = false:silent -csharp_style_expression_bodied_constructors = false:silent -csharp_style_expression_bodied_operators = false:silent -csharp_style_expression_bodied_properties = true:silent -csharp_style_expression_bodied_indexers = true:silent -csharp_style_expression_bodied_accessors = true:silent -csharp_style_expression_bodied_lambdas = true:silent -csharp_style_expression_bodied_local_functions = false:silent -csharp_style_throw_expression = true:suggestion -csharp_style_prefer_null_check_over_type_check = true:suggestion -csharp_prefer_simple_default_expression = true:suggestion -csharp_style_prefer_local_over_anonymous_function = true:suggestion -csharp_style_prefer_index_operator = true:suggestion -csharp_style_prefer_range_operator = true:suggestion -csharp_style_prefer_tuple_swap = true:suggestion -csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion -csharp_style_inlined_variable_declaration = true:suggestion -csharp_style_deconstructed_variable_declaration = true:suggestion -csharp_style_unused_value_assignment_preference = discard_variable:suggestion -csharp_style_unused_value_expression_statement_preference = discard_variable:silent -csharp_prefer_static_local_function = true:suggestion -csharp_style_allow_embedded_statements_on_same_line_experimental = true:silent -csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true:silent -csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:silent -csharp_style_conditional_delegate_call = true:suggestion -csharp_style_prefer_parameter_null_checking = true:suggestion -csharp_style_prefer_switch_expression = true:suggestion -csharp_style_prefer_pattern_matching = true:silent -csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion -csharp_style_pattern_matching_over_as_with_null_check = true:suggestion -csharp_style_prefer_not_pattern = true:suggestion -csharp_style_prefer_extended_property_pattern = true:suggestion -csharp_style_var_for_built_in_types = false:silent -csharp_style_var_when_type_is_apparent = false:silent -csharp_style_var_elsewhere = false:silent -csharp_style_prefer_top_level_statements = true:silent -dotnet_diagnostic.IDE0005.severity = suggestion -dotnet_diagnostic.IDE0063.severity = warning -dotnet_diagnostic.IDE0065.severity = warning -dotnet_diagnostic.CA1001.severity = warning -dotnet_diagnostic.CA1309.severity = silent -dotnet_diagnostic.CA1805.severity = warning -dotnet_diagnostic.IDE0036.severity = suggestion -dotnet_diagnostic.IDE0060.severity = warning +# IDE0017: Simplify object initialization +dotnet_diagnostic.IDE0017.severity = silent + +# CA1416: Validate platform compatibility +dotnet_diagnostic.CA1416.severity = none [*.{cs,vb}] #### Naming styles #### @@ -66,10 +19,6 @@ dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion dotnet_naming_rule.types_should_be_pascal_case.symbols = types dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case -dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion -dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members -dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case - # Symbol specifications dotnet_naming_symbols.interface.applicable_kinds = interface @@ -80,10 +29,6 @@ dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected dotnet_naming_symbols.types.required_modifiers = -dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method -dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.non_field_members.required_modifiers = - # Naming styles dotnet_naming_style.begins_with_i.required_prefix = I @@ -95,15 +40,6 @@ dotnet_naming_style.pascal_case.required_prefix = dotnet_naming_style.pascal_case.required_suffix = dotnet_naming_style.pascal_case.word_separator = dotnet_naming_style.pascal_case.capitalization = pascal_case - -dotnet_naming_style.pascal_case.required_prefix = -dotnet_naming_style.pascal_case.required_suffix = -dotnet_naming_style.pascal_case.word_separator = -dotnet_naming_style.pascal_case.capitalization = pascal_case -dotnet_style_operator_placement_when_wrapping = beginning_of_line -tab_width = 4 -indent_size = 4 -end_of_line = crlf dotnet_style_coalesce_expression = true:suggestion dotnet_style_null_propagation = true:suggestion dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion @@ -119,23 +55,27 @@ dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion dotnet_style_prefer_compound_assignment = true:suggestion dotnet_style_prefer_simplified_interpolation = true:suggestion dotnet_style_namespace_match_folder = true:suggestion -dotnet_style_readonly_field = true:suggestion -dotnet_style_predefined_type_for_locals_parameters_members = true:silent -dotnet_style_predefined_type_for_member_access = true:silent -dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent -dotnet_style_allow_multiple_blank_lines_experimental = true:silent -dotnet_style_allow_statement_immediately_after_block_experimental = true:silent -dotnet_code_quality_unused_parameters = all:suggestion -dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent -dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent -dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent -dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent -dotnet_style_qualification_for_field = false:silent -dotnet_style_qualification_for_property = false:silent -dotnet_style_qualification_for_method = false:silent -dotnet_style_qualification_for_event = false:silent -dotnet_diagnostic.CA1000.severity = suggestion -dotnet_diagnostic.CA1036.severity = error -dotnet_diagnostic.CA1707.severity = suggestion -dotnet_diagnostic.CA1711.severity = suggestion -dotnet_diagnostic.CA1710.severity = warning +dotnet_style_operator_placement_when_wrapping = beginning_of_line +tab_width = 4 +indent_size = 4 +end_of_line = crlf + +[*.cs] +csharp_using_directive_placement = outside_namespace:silent +csharp_prefer_simple_using_statement = true:suggestion +csharp_prefer_braces = true:silent +csharp_style_namespace_declarations = block_scoped:silent +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_top_level_statements = true:silent +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_throw_expression = true:suggestion +csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion +csharp_indent_labels = flush_left \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9491a2f..cc858b2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore # User-specific files +Internals*/ + *.rsuser *.suo *.user diff --git a/README.md b/README.md index 6a0762a..8a0c82b 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,31 @@ ## 📌 rowsSharp -C#/WPF-based CSV filtering and editing tool. Rewritten from [Rows](https://github.com/haruki-taka8/rows). +C#/WPF-based CSV filtering and editing tool. Rewritten and enhanced from [Rows](https://github.com/haruki-taka8/rows).

## ✔️ Features **Filtering** * Display user-friendly aliases instead of raw data - * Display a preview image based on the selected row - * Conditional formatting support - * Regular expressions support + * Display an image based on the selected row + * Conditional formatting + * Regular expressions
**Editing** - * Add/remove rows + * Add/edit/remove rows and columns * Add rows with a template - * Change existing rows * Unlimited undo & redo + * Shortcut keys
-## 🌎 Using -* [CsvHelper](https://joshclose.github.io/CsvHelper/) -* [Microsoft.Xaml.Behaviors.Wpf](https://github.com/microsoft/XamlBehaviorsWpf) -* [NLog](https://nlog-project.org/) +## 🌎 Dependencies +* [CsvHelper](https://joshclose.github.io/CsvHelper/) ([License](https://github.com/JoshClose/CsvHelper/blob/master/LICENSE.txt)) +* [Material Symbols](https://github.com/marella/material-symbols/) ([License](https://github.com/marella/material-symbols/blob/main/LICENSE)) + * Font was converted to `.otf` for WPF compatibility + * Font content is rotated and/or flipped inside the UI +* [Microsoft.Xaml.Behaviors.Wpf](https://github.com/microsoft/XamlBehaviorsWpf) ([License](https://github.com/microsoft/XamlBehaviorsWpf/blob/master/LICENSE)) +* [NLog](https://nlog-project.org/) ([License](https://github.com/NLog/NLog/blob/dev/LICENSE.txt))
## 🧪 Building -Official binaries are built with Visual Studio 2022 and .NET 6, using the _release_ configuration. +Official binaries are built with Visual Studio 2022 and .NET 6 using the _release_ configuration. diff --git a/rowsSharp/App.xaml b/rowsSharp/App.xaml index c62d225..8a5fd5a 100644 --- a/rowsSharp/App.xaml +++ b/rowsSharp/App.xaml @@ -1,12 +1,24 @@  + StartupUri="View/MainWindow.xaml"> + + pack://application,,,/View/Font/#Material Symbols Outlined 48pt + - - + + + + + + + + + + + diff --git a/rowsSharp/App.xaml.cs b/rowsSharp/App.xaml.cs index c68ec6d..234ac5c 100644 --- a/rowsSharp/App.xaml.cs +++ b/rowsSharp/App.xaml.cs @@ -5,19 +5,17 @@ namespace rowsSharp; public partial class App : Application { - internal static readonly NLog.Logger Logger = NLog.LogManager.GetCurrentClassLogger(); + internal static NLog.Logger Logger => NLog.LogManager.GetCurrentClassLogger(); private App() { - AppDomain.CurrentDomain.UnhandledException += new(Handler); + AppDomain.CurrentDomain.UnhandledException += Handler; } private void Handler(object sender, UnhandledExceptionEventArgs args) { - var exception = (Exception)args.ExceptionObject; - MessageBox.Show( - "RowsSharp will close due to the following exception:\n\n" + exception.Message + "\n\n" + exception.StackTrace, + "RowsSharp will close due to the following error:\n\n" + args.ExceptionObject, "RowsSharp", MessageBoxButton.OK, MessageBoxImage.Error diff --git a/rowsSharp/AssemblyInfo.cs b/rowsSharp/AssemblyInfo.cs index 66a9ebf..03b1ce6 100644 --- a/rowsSharp/AssemblyInfo.cs +++ b/rowsSharp/AssemblyInfo.cs @@ -13,4 +13,4 @@ [assembly: AssemblyProduct("RowsSharp")] [assembly: AssemblyCompany("haruki-taka8")] [assembly: AssemblyCopyright("MIT License, see LICENSE file")] -[assembly: AssemblyVersion("22.12.*")] \ No newline at end of file +[assembly: AssemblyVersion("23.06.*")] \ No newline at end of file diff --git a/rowsSharp/Internal/Command.cs b/rowsSharp/Command.cs similarity index 55% rename from rowsSharp/Internal/Command.cs rename to rowsSharp/Command.cs index 4d02870..2475a51 100644 --- a/rowsSharp/Internal/Command.cs +++ b/rowsSharp/Command.cs @@ -25,32 +25,43 @@ public event EventHandler? CanExecuteChanged } } -public class DelegateCommand : ICommand where T : class +/* + * The following constructors deliberately ALLOW the use of value type, + * despite Microsoft.Practices.Prism.Commands doing otherwise. + * + * It can be safely assumed that CanExecute(null) will not cause any + * issues. + */ + +public class DelegateCommand : ICommand { - private readonly Predicate _canExecute; + private readonly Func _canExecute; private readonly Action _execute; - internal DelegateCommand(Action execute) : this(execute, (T obj) => true) { } - - internal DelegateCommand(Action execute, Predicate canExecute) + internal DelegateCommand(Action execute) : this(execute, (T parameter) => true) { } + internal DelegateCommand(Action execute, Func canExecute) { _execute = execute; _canExecute = canExecute; } - public bool CanExecute(object? parameter) => - _canExecute is not null - && parameter is not null - && _canExecute((T)parameter); + public bool CanExecute(object? parameter) + { + return parameter is T type + && _canExecute.Invoke(type); + } public void Execute(object? parameter) { - if (parameter is not null) { _execute((T)parameter); } + if (parameter is T type) + { + _execute(type); + } } - public event EventHandler? CanExecuteChanged; - public void RaiseCanExecuteChanged() + public event EventHandler? CanExecuteChanged { - if (CanExecuteChanged is not null) { CanExecuteChanged(this, EventArgs.Empty); } + add => CommandManager.RequerySuggested += value; + remove => CommandManager.RequerySuggested -= value; } } diff --git a/rowsSharp/Domain/BaseDir.cs b/rowsSharp/Domain/BaseDir.cs new file mode 100644 index 0000000..da98456 --- /dev/null +++ b/rowsSharp/Domain/BaseDir.cs @@ -0,0 +1,14 @@ +using System; + +namespace rowsSharp.Domain; + +internal static class BaseDir +{ + private const string Metavariable = "$baseDir"; + private static readonly string BasePath = Environment.CurrentDirectory + "/Userdata/"; + + internal static string Expand(string path) + { + return path.Replace(Metavariable, BasePath); + } +} diff --git a/rowsSharp/Domain/Cell.cs b/rowsSharp/Domain/Cell.cs new file mode 100644 index 0000000..3e36ace --- /dev/null +++ b/rowsSharp/Domain/Cell.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Windows.Controls; + +namespace rowsSharp.Domain; + +internal static class Cell +{ + private static IEnumerable WhereValid(this IList cells) + { + return cells.Where(x => x.IsValid); + } + + // Rows + internal static IEnumerable> Rows(this IList cells) + { + return cells.WhereValid() + .Select(x => (ObservableCollection)x.Item) + .Distinct(); + } + + internal static IEnumerable RowIndices(this IList cells, IList> records) + { + return cells.Rows() + .Select(x => records.IndexOf(x)); + } + + // Columns + internal static IEnumerable Columns(this IList cells) + { + return cells.WhereValid() + .Select(x => (string)x.Column.Header) + .Distinct(); + } + + internal static IEnumerable ColumnIndices(this IList cells, IList headers) + { + return cells.Columns() + .Select(x => headers.IndexOf(x)); + } + + // Cell + internal static int RowIndex(this DataGridCellInfo cell, IList> records) + { + return records.IndexOf((ObservableCollection)cell.Item); + } + + internal static int ColumnIndex(this DataGridCellInfo cell, IList headers) + { + return headers.IndexOf((string)cell.Column.Header); + } +} diff --git a/rowsSharp/Domain/ColumnNotation.cs b/rowsSharp/Domain/ColumnNotation.cs new file mode 100644 index 0000000..4dd70eb --- /dev/null +++ b/rowsSharp/Domain/ColumnNotation.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Linq; + +namespace rowsSharp.Domain; + +internal static class ColumnNotation +{ + internal static string Expand(string path, IList headers, IList row) + { + foreach (var (header, field) in headers.Zip(row)) + { + path = path.Replace("<" + header + ">", field); + } + return path; + } +} diff --git a/rowsSharp/Domain/ColumnStyleHelper.cs b/rowsSharp/Domain/ColumnStyleHelper.cs new file mode 100644 index 0000000..44fb906 --- /dev/null +++ b/rowsSharp/Domain/ColumnStyleHelper.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using System.Windows.Media; + +namespace rowsSharp.Domain; + +internal static class ColumnStyleHelper +{ + private static Style GetDefaultStyle(Type type) + { + return new( + type, + (Style)Application.Current.FindResource(type) + ); + } + + + internal static Style GetConditionalFormatting(this Dictionary> colorStyles, string header, int column) + { + Style style = GetDefaultStyle(typeof(DataGridCell)); + + colorStyles.TryGetValue(header, out var rules); + if (rules is null) { return style; } + + var triggers = GetDataTriggers(column, rules); + style.Triggers.AddRange(triggers); + + return style; + } + + private static IEnumerable GetDataTriggers(int column, IDictionary rules) + { + foreach (var (key, value) in rules) + { + yield return GetDataTrigger(column, key, value); + } + } + + private static DataTrigger GetDataTrigger(int column, string key, string color) + { + DataTrigger dataTrigger = new() + { + Binding = new Binding("[" + column + "]"), + Value = key + }; + + dataTrigger.Setters.Add(new Setter() + { + Property = Control.BackgroundProperty, + Value = new BrushConverter().ConvertFrom(color) + }); + + return dataTrigger; + } + + private static Style? editingElementStyle; + private static Style SetEditingElementStyle(bool allowMultiline) + { + Style style = GetDefaultStyle(typeof(TextBoxBase)); + + List setters = new() + { + new(TextBoxBase.AcceptsReturnProperty, allowMultiline), + new(TextBoxBase.PaddingProperty, new Thickness(1, 1, 0, 0)), + new(TextBoxBase.MarginProperty, new Thickness(-2)) + }; + + style.Setters.AddRange(setters); + return style; + } + + internal static Style GetEditingElementStyle(bool allowMultiline) + => editingElementStyle ??= SetEditingElementStyle(allowMultiline); +} diff --git a/rowsSharp/Domain/CsvFile.cs b/rowsSharp/Domain/CsvFile.cs new file mode 100644 index 0000000..4258c6f --- /dev/null +++ b/rowsSharp/Domain/CsvFile.cs @@ -0,0 +1,39 @@ +using Microsoft.Win32; +using ObservableTable.Core; +using ObservableTable.IO; +using System.IO; + +namespace rowsSharp.Domain; + +internal class CsvFile +{ + internal static ObservableTable Import(string path, bool hasHeader) + { + if (!File.Exists(path)) { return new(); } + + return Importer.FromFilePath(path, hasHeader); + } + + private static string RequestFilePath() + { + SaveFileDialog dialog = new() + { + Filter = "Comma-seperated values (*.csv)|*.csv|All files (*.*)|*.*", + DefaultExt = "csv" + }; + + dialog.ShowDialog(); + + return dialog.FileName; + } + + internal static void Export(string path, ObservableTable table, bool hasHeader) + { + if (!File.Exists(path)) + { + path = RequestFilePath(); + } + + Exporter.ToFile(path, table, hasHeader); + } +} diff --git a/rowsSharp/Domain/Filter.cs b/rowsSharp/Domain/Filter.cs new file mode 100644 index 0000000..6d46ea5 --- /dev/null +++ b/rowsSharp/Domain/Filter.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text.RegularExpressions; +using System.Windows.Data; + +namespace rowsSharp.Domain; + +internal class Filter +{ + private static readonly Regex splitBySpace = new(@"\s + (?= (?:\""[^\""] *\"" |[^\""]) *$)"); + private static readonly Regex splitByColon = new(@"(?> filter = new(); + + internal Dictionary> Alias { get; set; } = new(); + internal IList Headers { get; set; } = new List(); + internal string FilterText { get; set; } = ""; + internal ICollectionView CollectionView { get; private set; } + private ICollectionView? originalCollectionView; + + internal bool UseRegex { get; set; } + internal bool UseInputAlias { get; set; } + private bool useOutputAlias; + internal bool UseOutputAlias + { + get => useOutputAlias; + set + { + useOutputAlias = value; + if (value) + { + if (originalCollectionView != CollectionView) + { + originalCollectionView = CollectionView; + } + return; + } + if (originalCollectionView is null) { return; } + CollectionView = originalCollectionView; + } + } + + internal Filter(ICollectionView collectionView) + { + CollectionView = collectionView; + } + + internal ICollectionView Invoke() + { + try + { + SplitInput(); + if (UseRegex) + { + ValidateRegex(); + } + } + catch + { + CollectionView.Filter = (object obj) => false; + return CollectionView; + } + + CollectionView.Filter = Predicate; + + return UseOutputAlias ? OutputAlias() : CollectionView; + } + + private string InputAlias(string header, string value) + { + if (!Alias.TryGetValue(header, out var columnAlias)) { return value; } + + foreach ((string raw, string aliased) in columnAlias) + { + if (string.IsNullOrEmpty(aliased)) { continue; } + value = value.Replace(aliased, raw); + } + return value; + } + + private static string? OutputAlias(string? value, IDictionary alias) + { + foreach ((string raw, string aliased) in alias) + { + if (string.IsNullOrEmpty(raw)) { continue; } + value = value?.Replace(raw, aliased); + } + return value; + } + + private ICollectionView OutputAlias() + { + // Deep copy CollectionView + var tempRecords = CollectionView.Cast(); + + // Apply alias per COLUMN + foreach (var (header, alias) in Alias) + { + int index = Headers.IndexOf(header); + foreach (var record in tempRecords) + { + record[index] = OutputAlias(record[index], alias); + } + } + return CollectionViewSource.GetDefaultView(tempRecords); + } + + private void SplitInput() + { + filter.Clear(); + if (string.IsNullOrEmpty(FilterText)) { return; } + + var filters = splitBySpace.Split(FilterText); + + foreach (string criterion in filters) + { + string[] keyvalue = splitByColon.Split(criterion.Replace("\"", "")); + + // Value only + if (keyvalue.Length == 1) + { + ParseValue(keyvalue[0]); + continue; + } + + // Key:Value + ParseKeyValue(keyvalue); + } + } + + private void ParseValue(string criterion) + { + filter.Add(new(-1, criterion)); + } + + private void ParseKeyValue(string[] criterion) + { + int column = Headers.IndexOf(criterion[0]); + + if (column == -1) + { + throw new IndexOutOfRangeException($"Invalid column {criterion[0]}"); + } + + if (UseInputAlias) + { + criterion[1] = InputAlias(criterion[0], criterion[1]); + } + + filter.Add(new(column, criterion[1])); + } + + private void ValidateRegex() + { + foreach ((int column, string criterion) in filter) + { + if (column == -1) + { + continue; + } + + // Throws exception on invalid regex + Regex.IsMatch("", criterion); + } + } + + private static string ToCsvString(IList row) + { + return '"' + string.Join("\",\"", row) + '"'; + } + + private bool Predicate(object obj) + { + var row = (IList)obj; + foreach ((int column, string pattern) in filter) + { + string input = (column == -1 ? ToCsvString(row) : row[column]) ?? ""; + + if ( + (UseRegex && Regex.IsMatch(input, pattern, RegexOptions.IgnoreCase)) + || (!UseRegex && input.Contains(pattern, StringComparison.InvariantCultureIgnoreCase)) + ) + { continue; } + return false; + } + return true; + } +} diff --git a/rowsSharp/Domain/PreferencesReader.cs b/rowsSharp/Domain/PreferencesReader.cs new file mode 100644 index 0000000..d239987 --- /dev/null +++ b/rowsSharp/Domain/PreferencesReader.cs @@ -0,0 +1,69 @@ +using rowsSharp.Model; +using System; +using System.IO; +using System.Text.Json; +using System.Windows; +using System.Windows.Markup; + +namespace rowsSharp.Domain; + +internal static class PreferencesReader +{ + internal static Preferences Import(string path) + { + App.Logger.Info("Reading preferences"); + + Preferences config = FromPath(path); + config.ExpandBaseDir(); + config.ApplyTheming(); + return config; + } + + private static Preferences FromPath(string path) + { + try + { + string json = File.ReadAllText(path); + return JsonSerializer.Deserialize(json) ?? new(); + } + catch (Exception ex) + { + App.Logger.Fatal(ex, "Error reading preferences"); + throw; + } + } + + private static void ExpandBaseDir(this Preferences config) + { + config.CsvPath = BaseDir.Expand(config.CsvPath); + config.PreviewPath = BaseDir.Expand(config.PreviewPath); + config.StylePath = BaseDir.Expand(config.StylePath); + config.ThemePath = BaseDir.Expand(config.ThemePath); + } + + private static void ApplyTheming(this Preferences config) + { + config.ColumnStyle = GetColumnStyle(config.StylePath); + config.Theme = GetTheme(config.ThemePath); + } + + private static ColumnStyle GetColumnStyle(string path) + { + if (!File.Exists(path)) { return new(); } + + App.Logger.Info("Parsing conditional formatting configurations"); + + string json = File.ReadAllText(path); + return JsonSerializer.Deserialize(json) ?? new(); + } + + private static ResourceDictionary GetTheme(string path) + { + if (!File.Exists(path)) { return new(); } + + App.Logger.Info("Parsing XAML theme file"); + + string xaml = File.ReadAllText(path); + return (ResourceDictionary)XamlReader.Parse(xaml); + } +} diff --git a/rowsSharp/Domain/Preview.cs b/rowsSharp/Domain/Preview.cs new file mode 100644 index 0000000..a6b6131 --- /dev/null +++ b/rowsSharp/Domain/Preview.cs @@ -0,0 +1,19 @@ +using System.IO; +using System.Windows.Media.Imaging; + +namespace rowsSharp.Domain; + +internal static class Preview +{ + internal static BitmapImage? FromPath(string path) + { + if (!File.Exists(path)) { return null; } + + BitmapImage image = new(); + image.BeginInit(); + image.CacheOption = BitmapCacheOption.OnLoad; + image.UriSource = new(path); + image.EndInit(); + return image; + } +} diff --git a/rowsSharp/Domain/RowTemplate.cs b/rowsSharp/Domain/RowTemplate.cs new file mode 100644 index 0000000..4f5bc5f --- /dev/null +++ b/rowsSharp/Domain/RowTemplate.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace rowsSharp.Domain; + +internal static class RowTemplate +{ + internal static IEnumerable Generate(int count, IList headers, IDictionary? template) + { + for (int i = 0; i < count; i++) + { + var row = new string?[headers.Count]; + + if (template is not null) + { + row = ApplyTemplate(row, i, count, headers, template); + } + + yield return row; + } + } + + private static string?[] ApplyTemplate(string?[] row, int rowIndex, int count, IList headers, IDictionary template) + { + foreach (var (column, value) in template) + { + int index = headers.IndexOf(column); + if (index == -1) { continue; } + + row[index] = Expand(value, rowIndex, count); + } + return row; + } + + private static string ExpandOriginalNotation(string value) + { + // These will be deprecated soon. + return value.Replace("", "") + .Replace("", "") + .Replace("", "") + .Replace("", ""); + } + + private readonly static Regex column = new("(?<=<)(.+?)(?=>)"); + + private static string ExpandDateTime(string value) + { + var matches = column.Matches(value).OfType(); + var now = DateTime.Now; + + foreach (var match in matches) + { + try + { + value = value.Replace("<" + match + ">", now.ToString(match.Value)); + } + catch (FormatException) { } + } + + return value; + } + + private static string ExpandIndexer(string value, int rowIndex, int count) + { + return value.Replace("<#>", rowIndex.ToString()) + .Replace("", (count - rowIndex - 1).ToString()); + } + + private static string? Expand(string? value, int rowIndex, int count) + { + if (value is null) { return null; } + + value = ExpandIndexer(value, rowIndex, count); + value = ExpandOriginalNotation(value); + value = ExpandDateTime(value); + + return value; + } +} diff --git a/rowsSharp/Domain/UniqueColumn.cs b/rowsSharp/Domain/UniqueColumn.cs new file mode 100644 index 0000000..a714b19 --- /dev/null +++ b/rowsSharp/Domain/UniqueColumn.cs @@ -0,0 +1,22 @@ +using ObservableTable.Core; +using System.Collections.Generic; + +namespace rowsSharp.Domain; + +internal class UniqueColumn +{ + private int index; + + private static Column GetNumberedColumn(int index) + { + return new(index.ToString()); + } + + internal IEnumerable> Next(int count = 1) + { + for (int i = 0; i < count; i++) + { + yield return GetNumberedColumn(index++); + } + } +} diff --git a/rowsSharp/Internal/INPC.cs b/rowsSharp/INotifyPropertyChanged.cs similarity index 75% rename from rowsSharp/Internal/INPC.cs rename to rowsSharp/INotifyPropertyChanged.cs index 6268ca3..0771dcf 100644 --- a/rowsSharp/Internal/INPC.cs +++ b/rowsSharp/INotifyPropertyChanged.cs @@ -3,12 +3,12 @@ namespace rowsSharp; -public abstract class INPC : INotifyPropertyChanged +public abstract class NotifyPropertyChanged : INotifyPropertyChanged { public event PropertyChangedEventHandler? PropertyChanged; public void OnPropertyChanged(string propertyName) { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + PropertyChanged?.Invoke(this, new(propertyName)); } private protected void SetField(ref T field, T value, [CallerMemberName] string propertyName = "") diff --git a/rowsSharp/Internal/DataContext.cs b/rowsSharp/Internal/DataContext.cs deleted file mode 100644 index c5fde34..0000000 --- a/rowsSharp/Internal/DataContext.cs +++ /dev/null @@ -1,73 +0,0 @@ -using rowsSharp.DataStore; -using rowsSharp.Domain; -using rowsSharp.ViewModel; -using System.ComponentModel; -using System.Linq; -using System.Windows; - -namespace rowsSharp; - -internal class DataContext : INPC -{ - // DataStore - public OperationHistory OperationHistory { get; } = new(); - public Config Config { get; } - public Csv Csv { get; } - - // ViewModel - public Command Command { get; } - public RecordsView RecordsView { get; } - public Status Status { get; } = new(); - - // Domain - internal Filter Filter { get; } - internal Preview Preview { get; } - internal Edit Edit { get; } - internal History History { get; } - - internal DataContext() - { - Config = Domain.IO.Config.Import(); - - Csv = Domain.IO.Csv.Import(Config.CsvPath, Config.HasHeader); - RecordsView = new(Csv.Records); - - Filter = new(Status, Config, Csv, RecordsView); - Preview = new(Status, Csv, Config.PreviewPath, Config.CopyRowFormat); - History = new(Status, OperationHistory, Csv); - Edit = new(Status, Config, Csv, History); - Command = new(this); - - if (!Csv.Records.Any()) - { - App.Logger.Warn("CSV file not found. Starting creation wizard."); - _ = new InitWizardWindow(Config); - - Csv = Domain.IO.Csv.Import(Config.CsvPath, Config.HasHeader); - RecordsView = new(Csv.Records); - } - } - - public DelegateCommand Exit => new( - (e) => - { - App.Logger.Info("Changes unsaved, asking for confirmation before exiting."); - MessageBoxResult dialog = MessageBox.Show( - "Save changes before exiting?", - "RowsSharp", - MessageBoxButton.YesNoCancel, - MessageBoxImage.Question - ); - - if (dialog == MessageBoxResult.Cancel) - { - e.Cancel = true; - } - else if (dialog == MessageBoxResult.Yes) - { - Edit.Save(); - } - }, - (e) => Status.IsDirtyEditor - ); -} diff --git a/rowsSharp/Internal/DataStore/Store.Config.cs b/rowsSharp/Internal/DataStore/Store.Config.cs deleted file mode 100644 index bd0e7e3..0000000 --- a/rowsSharp/Internal/DataStore/Store.Config.cs +++ /dev/null @@ -1,46 +0,0 @@ -using rowsSharp.Model; - -namespace rowsSharp.DataStore; - -public class Config : INPC -{ - public string CsvPath { get; set; } = "$baseDir/CSVData/data.csv"; - public bool HasHeader { get; init; } = true; - public string StylePath { get; set; } = string.Empty; - public string PreviewPath { get; set; } = string.Empty; - public string CopyRowFormat { get; init; } = string.Empty; - - public bool UseInputAlias { get; set; } - - private bool useOutputAlias; - public bool UseOutputAlias - { - get => useOutputAlias; - set => SetField(ref useOutputAlias, value); - } - - public bool UseRegexFilter { get; init; } - public bool UseToolTip { get; init; } = true; - - private bool canEdit; - public bool CanEdit - { - get => canEdit; - set => SetField(ref canEdit, value); - } - - internal bool OriginalCanEdit { get; set; } = true; - public bool AllowMultiline { get; set; } = true; - - private bool insertSelectedCount; - public bool InsertSelectedCount - { - get => insertSelectedCount; - set => SetField(ref insertSelectedCount, value); - } - - public int InsertCount { get; init; } - public bool UseInsertTemplate { get; set; } - public string ThemePath { get; set; } = "$baseDir/Configurations/Themes/Light.xaml"; - public ColumnStyle Style { get; set; } -} diff --git a/rowsSharp/Internal/DataStore/Store.Csv.cs b/rowsSharp/Internal/DataStore/Store.Csv.cs deleted file mode 100644 index c723293..0000000 --- a/rowsSharp/Internal/DataStore/Store.Csv.cs +++ /dev/null @@ -1,11 +0,0 @@ -using rowsSharp.Model; -using System.Collections.Generic; -using System.Collections.ObjectModel; - -namespace rowsSharp.DataStore; - -internal class Csv : INPC -{ - internal List Headers = new(); - internal ObservableCollection Records = new(); -} diff --git a/rowsSharp/Internal/DataStore/Store.OperationHistory.cs b/rowsSharp/Internal/DataStore/Store.OperationHistory.cs deleted file mode 100644 index 1607856..0000000 --- a/rowsSharp/Internal/DataStore/Store.OperationHistory.cs +++ /dev/null @@ -1,10 +0,0 @@ -using rowsSharp.Model; -using System.Collections.Generic; - -namespace rowsSharp.DataStore; - -internal class OperationHistory -{ - internal Stack UndoStack = new(); - internal Stack RedoStack = new(); -} diff --git a/rowsSharp/Internal/Domain/Domain.Edit.cs b/rowsSharp/Internal/Domain/Domain.Edit.cs deleted file mode 100644 index 8d2274e..0000000 --- a/rowsSharp/Internal/Domain/Domain.Edit.cs +++ /dev/null @@ -1,147 +0,0 @@ -using rowsSharp.Model; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Windows.Controls; - -namespace rowsSharp.Domain; -internal class Edit -{ - private readonly ViewModel.Status status; - private readonly DataStore.Config config; - private readonly DataStore.Csv csv; - private readonly History history; - - internal Edit (ViewModel.Status status, DataStore.Config config, DataStore.Csv csv, History history) - { - this.status = status; - this.config = config; - this.csv = csv; - this.history = history; - } - - internal void OutputAliasEditing() - { - // Make ReadWrite FALSE when OutputAlias is TRUE - // Revert OriginalCanEdit when OutputAlias is FALSE - if (config.UseOutputAlias) { config.OriginalCanEdit = config.CanEdit; } - config.CanEdit = !config.UseOutputAlias && config.OriginalCanEdit; - } - - internal void BeginEdit() { status.IsEditing = true; } - - internal void EndEdit(DataGridCellEditEndingEventArgs e) - { - var record = (Record)e.Row.Item; - int columnIndex = csv.Headers.IndexOf(e.Column.Header.ToString()!); - string oldString = record.GetField(columnIndex); - - if (((TextBox)e.EditingElement).Text == oldString) { return; } - - history.AddOperation( - OperationType.Inline, - csv.Records.IndexOf(record), - record.DeepCopy(csv.Headers.Count) - ); - history.CommitOperation(); - } - - internal bool CanInsertTopOrBottom() => - config.CanEdit && - ( - (config.InsertSelectedCount && status.SelectedItems.Any()) || - (!config.InsertSelectedCount) - ); - - internal bool IsAnyRowSelected() => - config.CanEdit && status.SelectedIndex != -1; - - internal void Remove() - { - App.Logger.Info("Removing rows (x{Count})", status.SelectedItems.Count); - foreach (Record item in status.SelectedItems) - { - history.AddOperation( - OperationType.Remove, - csv.Records.IndexOf(item), - item - ); - csv.Records.Remove(item); - } - history.CommitOperation(); - } - - internal void Insert(int at) - { - if (at == -1) { at = csv.Records.Count; } - - int count = config.InsertSelectedCount - ? status.SelectedItems.Count - : config.InsertCount; - - App.Logger.Info("Inserting CSV (@{At} x{Count}, Template: {Template})", at, count, config.UseInsertTemplate); - status.IsInsertExpanded = false; - - DateTime now = DateTime.Now; - Record templatedRow = new(); - - // Templating. Expand static <[DdTt]> fields beforehand. - if (config.UseInsertTemplate) - { - foreach (KeyValuePair keyValuePair in config.Style.Template) - { - int columnIndex = csv.Headers.IndexOf(keyValuePair.Key); - templatedRow.SetField( - columnIndex, - keyValuePair.Value - .Replace("", now.ToString("yyyyMMdd")) - .Replace("", now.ToString("yyyy-MM-dd")) - .Replace("", now.ToString("HHmmss")) - .Replace("", now.ToString("HH:mm:ss")) - ); - } - } - - // Insert - for (int i = 0; i < count; i++) - { - Record thisRow = templatedRow.DeepCopy(csv.Headers.Count); - for (int j = 0; j < csv.Headers.Count; j++) - { - thisRow.SetField( - j, - thisRow.GetField(j) - .Replace("<#>", i.ToString()) - .Replace("", (count - i - 1).ToString()) - ); - } - - csv.Records.Insert(at + i, thisRow); - history.AddOperation(OperationType.Insert, at + i, thisRow); - } - history.CommitOperation(); - status.ScrollAfterInsert = true; - status.SelectedIndex = at; - } - - internal void Save() - { - App.Logger.Info("Saving"); - using StreamWriter writer = new(config.CsvPath); - - string fullHeader = string.Join( - ",", - csv.Headers.Select(m => "\"" + m.Replace("\"", "\"\"") + "\"") - ); - writer.WriteLine(fullHeader); - - foreach (Record record in csv.Records) - { - string toOutput = record.ConcatenateFields(csv.Headers.Count); - writer.WriteLine(toOutput); - } - - status.IsDirtyEditor = false; - } -} diff --git a/rowsSharp/Internal/Domain/Domain.Filter.cs b/rowsSharp/Internal/Domain/Domain.Filter.cs deleted file mode 100644 index b7e513c..0000000 --- a/rowsSharp/Internal/Domain/Domain.Filter.cs +++ /dev/null @@ -1,163 +0,0 @@ -using rowsSharp.Model; -using rowsSharp.ViewModel; -using System.Collections.Generic; -using System.ComponentModel; -using System.Reflection; -using System.Text.RegularExpressions; -using System.Windows.Data; - -namespace rowsSharp.Domain; - -internal class Filter -{ - private readonly Status status; - private readonly DataStore.Config config; - private readonly DataStore.Csv csv; - private readonly RecordsView recordsView; - - internal Filter(Status inputStatus, DataStore.Config inputConfig, DataStore.Csv inputCsv, RecordsView inputRecordsView) - { - status = inputStatus; - config = inputConfig; - csv = inputCsv; - recordsView = inputRecordsView; - } - - internal void FocusFilter() - { - // Force update ViewModel by refocusing - status.IsFilterFocused = false; - status.IsFilterFocused = true; - } - - private List> criteria = new(); - - private List> ParseInput() - { - List> output = new(); - - string[] splitFilterText = Regex.Split(status.FilterText, "\\s+(?=(?:\"[^\"]*\"|[^\"])*$)"); - foreach (string criterion in splitFilterText) - { - string[] keyvalue = Regex.Split(criterion, ":(?=(?:\"[^\"]*\"|[^\"])*$)"); - // Handle value-only criterion (default) - int column = -1; - string value = keyvalue[^1].Trim('"'); - - // Extra handling for Key:Value - if (keyvalue.Length == 2) - { - string header = keyvalue[0].Trim('"'); - column = csv.Headers.IndexOf(header); - - if (column == -1) - { - throw new InvalidFilterCriteriaException($"Invalid column {header}"); - } - - // Input alias - value = keyvalue[1].Trim('"'); - if (config.UseInputAlias) - { - Dictionary columnAlias = config.Style.Alias.GetValueOrDefault(header, new()); - foreach ((string raw, string aliased) in columnAlias) - { - value = value.Replace(aliased, raw); - } - } - } - - // Validate regular expression - if (config.UseRegexFilter) - { - try - { - Regex.IsMatch("", value); - } - catch - { - throw new InvalidFilterCriteriaException($"Invalid regex {value}"); - } - } - output.Add(new(column, value)); - } - return output; - } - - private ICollectionView OutputAlias() - { - List tempRecords = new(); - foreach (Record record in recordsView.CollectionView) - { - Record thisRecord = record.DeepCopy(csv.Headers.Count); - for (int i = 0; i < csv.Headers.Count; i++) - { - Dictionary thisAlias = config.Style.Alias.GetValueOrDefault(csv.Headers[i], new()); - foreach ((string raw, string aliased) in thisAlias) - { - thisRecord.SetField( - i, - thisRecord.GetField(i).Replace(raw, aliased) - ); - } - } - tempRecords.Add(thisRecord); - } - return CollectionViewSource.GetDefaultView(tempRecords); - } - - private bool RecordsViewFilter(object obj) - { - var row = (Record)obj; - foreach ((int column, string pattern) in criteria) - { - string input = column == -1 - ? row.ConcatenateFields(csv.Headers.Count) - : row.GetField(column); - - if ( - (config.UseRegexFilter && Regex.IsMatch(input.ToLower(), pattern)) - || (!config.UseRegexFilter && input.ToLower().Contains(pattern.ToLower())) - ) - { continue; } - return false; - } - return true; - } - - internal void DoFilter() - { - App.Logger.Info("Filtering CSV, ({filter}, IOAlias: {IAlias}, {OAlias})", - status.FilterText, - config.UseInputAlias, - config.UseOutputAlias - ); - - // Parse input - try - { - criteria = ParseInput(); - } - catch (InvalidFilterCriteriaException ex) - { - App.Logger.Warn(ex.Message); - recordsView.CollectionView.Filter = (object obj) => false; - return; - } - - // Filtering - recordsView.CollectionView = CollectionViewSource.GetDefaultView(csv.Records); - recordsView.CollectionView.Filter = RecordsViewFilter; - - // Output alias - if (config.UseOutputAlias) - { - recordsView.CollectionView = OutputAlias(); - } - - criteria = new(); - status.PreviewBitmap = new(); - status.SelectedIndex = -1; - App.Logger.Debug("Filtering CSV completed"); - } -} diff --git a/rowsSharp/Internal/Domain/Domain.History.cs b/rowsSharp/Internal/Domain/Domain.History.cs deleted file mode 100644 index 9020e5c..0000000 --- a/rowsSharp/Internal/Domain/Domain.History.cs +++ /dev/null @@ -1,90 +0,0 @@ -using rowsSharp.Model; -using System; -using System.Windows; - -namespace rowsSharp.Domain; -internal class History -{ - private readonly ViewModel.Status status; - private readonly DataStore.OperationHistory operation; - private readonly DataStore.Csv csv; - internal History(ViewModel.Status status, DataStore.OperationHistory operation, DataStore.Csv csv) - { - this.status = status; - this.operation = operation; - this.csv = csv; - } - - private bool parity; - public void AddOperation(OperationType operationType, int at, Record oldRow) - { - operation.UndoStack.Push(new Operation(operationType, at, oldRow, parity)); - } - - public void CommitOperation() - { - operation.RedoStack.Clear(); - status.IsDirtyEditor = true; - parity = !parity; - } - - private static void DispatcherInvoke(Action action) - { - Application.Current.Dispatcher.BeginInvoke(action); - } - - private void CommonOperation(bool isUndo, Operation last) - { - OperationType action = last.OperationType; - if (action == OperationType.Inline) - { - DispatcherInvoke(() => csv.Records[last.At] = last.OldRow); - } - - else if ((isUndo && action == OperationType.Remove) || - (!isUndo && action == OperationType.Insert) - ) - { - DispatcherInvoke(() => csv.Records.Insert(last.At, last.OldRow)); - } - else - { - DispatcherInvoke(() => csv.Records.RemoveAt(last.At)); - } - } - - internal void Undo() - { - Operation last = operation.UndoStack.Pop(); - App.Logger.Info("Undo {Action} @ {At}, {Parity}", last.OperationType, last.At, last.Parity); - - operation.RedoStack.Push(new Operation( - last.OperationType, - last.At, - last.OperationType == OperationType.Inline ? csv.Records[last.At] : last.OldRow, - last.Parity - )); - CommonOperation(true, last); - - // Group insert/remove edits - operation.UndoStack.TryPeek(out Operation? next); - if (last.Parity == next?.Parity) { Undo(); } - } - - internal void Redo() - { - Operation last = operation.RedoStack.Pop(); - App.Logger.Info("Redo {Action} @ {At}, {Parity}", last.OperationType, last.At, last.Parity); - - operation.UndoStack.Push(new Operation( - last.OperationType, - last.At, - last.OperationType == OperationType.Inline ? csv.Records[last.At] : last.OldRow, - last.Parity - )); - CommonOperation(false, last); - - operation.RedoStack.TryPeek(out Operation? next); - if (last.Parity == next?.Parity) { Redo(); } - } -} diff --git a/rowsSharp/Internal/Domain/Domain.IO.Config.cs b/rowsSharp/Internal/Domain/Domain.IO.Config.cs deleted file mode 100644 index deed83f..0000000 --- a/rowsSharp/Internal/Domain/Domain.IO.Config.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System; -using System.IO; -using System.Text.Json; -using System.Windows; -using System.Windows.Markup; - -namespace rowsSharp.Domain.IO; - -internal static class Config -{ - private const string ConfigPath = "./Userdata/Configurations/Configuration.json"; - - internal static DataStore.Config Import(string path = ConfigPath) - { - // General configuration - if (!File.Exists(path)) - { - FileNotFoundException ex = new(path); - App.Logger.Fatal(ex, "Base configuration file not found."); - throw ex; - } - - App.Logger.Info("Loading base configurations"); - string jsonString = File.ReadAllText(path); - DataStore.Config config = JsonSerializer.Deserialize(jsonString) ?? new(); - - config.OriginalCanEdit = config.CanEdit; - - string baseDir = Environment.CurrentDirectory + "./Userdata/"; - config.CsvPath = config.CsvPath.Replace("$baseDir", baseDir); - config.StylePath = config.StylePath.Replace("$baseDir", baseDir); - config.PreviewPath = config.PreviewPath.Replace("$baseDir", baseDir); - config.ThemePath = config.ThemePath.Replace("$baseDir", baseDir); - - // Conditional Formatting - if (File.Exists(config.StylePath)) - { - App.Logger.Info("Loading optional conditional formatting configurations"); - jsonString = File.ReadAllText(config.StylePath); - config.Style = JsonSerializer.Deserialize(jsonString); - } - - // Themeing - if (File.Exists(config.ThemePath)) - { - App.Logger.Info("Loading XAML theme file"); - using StreamReader streamReader = new(config.ThemePath); - try - { - var dictionary = (ResourceDictionary)XamlReader.Load(streamReader.BaseStream); - Application.Current.Resources.MergedDictionaries.Add(dictionary); - } - catch (XamlParseException e) - { - App.Logger.Error(e, "Error parsing XAML theme file"); - } - } - return config; - } -} diff --git a/rowsSharp/Internal/Domain/Domain.IO.Csv.cs b/rowsSharp/Internal/Domain/Domain.IO.Csv.cs deleted file mode 100644 index 9b78fdb..0000000 --- a/rowsSharp/Internal/Domain/Domain.IO.Csv.cs +++ /dev/null @@ -1,44 +0,0 @@ -using CsvHelper; -using CsvHelper.Configuration; -using rowsSharp.Model; -using System.Globalization; -using System.IO; -using System.Linq; - -namespace rowsSharp.Domain.IO; - -internal static class Csv -{ - internal static DataStore.Csv Import(string path, bool hasHeader) - { - if (!File.Exists(path)) { return new(); } - - CsvConfiguration config = new(CultureInfo.InvariantCulture) - { - MissingFieldFound = null, - BadDataFound = null, - HasHeaderRecord = hasHeader - }; - - App.Logger.Info("Loading CSV @ {path}", path); - using StreamReader reader = new(path); - using CsvReader csvReader = new(reader, config); - csvReader.Context.RegisterClassMap(); - - DataStore.Csv csv = new() - { - Records = new(csvReader.GetRecords()), - Headers = csvReader.Context.Reader.HeaderRecord?.ToList() ?? new() - }; - - // Default headers - if (csv.Headers.Any() || !csv.Records.Any()) { return csv; } - - for (int i = 0; i < RecordMap.MaxColumns - 1; i++) - { - if (csv.Records[0].GetField(i) == string.Empty) { break; } - csv.Headers.Add("Column" + i); - } - return csv; - } -} diff --git a/rowsSharp/Internal/Domain/Domain.InitWizard.cs b/rowsSharp/Internal/Domain/Domain.InitWizard.cs deleted file mode 100644 index 8bf5369..0000000 --- a/rowsSharp/Internal/Domain/Domain.InitWizard.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.IO; -using System.Text.RegularExpressions; - -namespace rowsSharp.Domain; - -internal class InitWizard : INPC -{ - private DataStore.Config config; - public DataStore.Config Config - { - get => config; - set => SetField(ref config, value); - } - - internal InitWizard(DataStore.Config config) - { - Config = this.config = config; - } - - private string initHeaders = string.Empty; - public string InitHeaders - { - get => initHeaders; - set => SetField(ref initHeaders, value); - } - - // private DelegateCommand? createCommand; - public DelegateCommand CreateCommand => new( - () => - { - string[] toWrite = - { - InitHeaders, - "Placeholder 1" - }; - File.WriteAllLines(Config.CsvPath, toWrite); - }, - () => !Regex.IsMatch(InitHeaders, @"^[,\s]*$") - ); -} diff --git a/rowsSharp/Internal/Domain/Domain.Preview.cs b/rowsSharp/Internal/Domain/Domain.Preview.cs deleted file mode 100644 index 8d47587..0000000 --- a/rowsSharp/Internal/Domain/Domain.Preview.cs +++ /dev/null @@ -1,80 +0,0 @@ -using rowsSharp.Model; -using System.IO; -using System.Linq; -using System.Text.RegularExpressions; -using System.Windows.Media.Imaging; -using System.Windows; -using System; - -namespace rowsSharp.Domain; - -internal class Preview -{ - private readonly ViewModel.Status status; - private readonly DataStore.Csv csv; - private readonly string previewPath; - private readonly string copyRowFormat; - - internal Preview(ViewModel.Status inputStatus, DataStore.Csv inputCsv, string inputPreviewPath, string inputCopyRowFormat) - { - status = inputStatus; - csv = inputCsv; - previewPath = inputPreviewPath; - copyRowFormat = inputCopyRowFormat; - } - - private string ExpandColumnNotation(string inString, Record activeRow) - { - MatchCollection matches = Regex.Matches(inString, @"(?<=<)(.+?)(?=>)"); - foreach (Match match in matches.Cast()) - { - int columnIndex = csv.Headers.IndexOf(match.Value); - if (columnIndex == -1) { return string.Empty; } - - string replaceFrom = string.Format("<{0}>", match.Value); - string replaceTo = activeRow.GetField(columnIndex); - - inString = inString.Replace(replaceFrom, replaceTo); - } - return inString; - } - - internal void UpdatePreview() - { - status.PreviewBitmap = new(); - if (!status.SelectedItems.Any()) { return; } - - string path = ExpandColumnNotation(previewPath, status.SelectedItems[0]); - - if (!File.Exists(path)) - { - App.Logger.Warn("Failed to set preview because of non-existent file @ {path}", path); - status.PreviewBitmap = new(); - return; - } - - // Don't permanently lock the image - App.Logger.Info("Setting preview to {path}", path); - - BitmapImage previewSource = new(); - previewSource.BeginInit(); - previewSource.UriSource = new Uri(path); - previewSource.CacheOption = BitmapCacheOption.OnLoad; - previewSource.EndInit(); - previewSource.Freeze(); - status.PreviewBitmap = previewSource; - } - - internal void CopyImage() - { - App.Logger.Info("Copying preview image"); - Clipboard.SetImage(status.PreviewBitmap); - } - - internal void CopyString() - { - App.Logger.Info("Copying row string"); - string toCopy = ExpandColumnNotation(copyRowFormat, status.SelectedItems[0]); - Clipboard.SetText(toCopy); - } -} diff --git a/rowsSharp/Internal/Model/Model.Operation.cs b/rowsSharp/Internal/Model/Model.Operation.cs deleted file mode 100644 index 1a0b4f1..0000000 --- a/rowsSharp/Internal/Model/Model.Operation.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace rowsSharp.Model; - -internal enum OperationType -{ - Inline, - Insert, - Remove -} - -internal class Operation -{ - internal OperationType OperationType { get; set; } - internal int At { get; set; } - internal Record OldRow { get; set; } = new(); - internal bool Parity { get; set; } - - internal static Operation DeepCopy(Operation copyFrom) - { - return new(copyFrom.OperationType, copyFrom.At, copyFrom.OldRow, copyFrom.Parity); - } - - internal Operation(OperationType operationType, int at, Record oldRow, bool parity) - { - OperationType = operationType; - At = at; - OldRow = oldRow; - Parity = parity; - } -} diff --git a/rowsSharp/Internal/Model/Model.Record.cs b/rowsSharp/Internal/Model/Model.Record.cs deleted file mode 100644 index c4d0733..0000000 --- a/rowsSharp/Internal/Model/Model.Record.cs +++ /dev/null @@ -1,99 +0,0 @@ -using CsvHelper.Configuration; - -namespace rowsSharp.Model; - -public class Record -{ - public string Column0 { get; set; } = string.Empty; - public string Column1 { get; set; } = string.Empty; - public string Column2 { get; set; } = string.Empty; - public string Column3 { get; set; } = string.Empty; - public string Column4 { get; set; } = string.Empty; - public string Column5 { get; set; } = string.Empty; - public string Column6 { get; set; } = string.Empty; - public string Column7 { get; set; } = string.Empty; - public string Column8 { get; set; } = string.Empty; - public string Column9 { get; set; } = string.Empty; - public string Column10 { get; set; } = string.Empty; - public string Column11 { get; set; } = string.Empty; - public string Column12 { get; set; } = string.Empty; - public string Column13 { get; set; } = string.Empty; - public string Column14 { get; set; } = string.Empty; - public string Column15 { get; set; } = string.Empty; - public string Column16 { get; set; } = string.Empty; - public string Column17 { get; set; } = string.Empty; - public string Column18 { get; set; } = string.Empty; - public string Column19 { get; set; } = string.Empty; - public string Column20 { get; set; } = string.Empty; - public string Column21 { get; set; } = string.Empty; - public string Column22 { get; set; } = string.Empty; - public string Column23 { get; set; } = string.Empty; - public string Column24 { get; set; } = string.Empty; - public string Column25 { get; set; } = string.Empty; - public string Column26 { get; set; } = string.Empty; - public string Column27 { get; set; } = string.Empty; - public string Column28 { get; set; } = string.Empty; - public string Column29 { get; set; } = string.Empty; - public string Column30 { get; set; } = string.Empty; - public string Column31 { get; set; } = string.Empty; - - internal string GetField(int column) - { - if (column < 0 || column > RecordMap.MaxColumns - 1) { return string.Empty; } - - // We're absolutely sure that - // record is not null && "Column" + column is a valid field - #pragma warning disable CS8602, CS8603 - return GetType() - .GetProperty("Column" + column) - .GetValue(this).ToString(); - #pragma warning restore CS8602, CS8603 - } - - internal void SetField(int column, string value) - { - if (column < 0 || column > RecordMap.MaxColumns - 1) { return; } - #pragma warning disable CS8602 - GetType() - .GetProperty("Column" + column) - .SetValue(this, value); - #pragma warning restore CS8602 - } - - internal Record DeepCopy(int columnCount = RecordMap.MaxColumns - 1) - { - Record output = new(); - for (int i = 0; i < columnCount; i++) - { - output.SetField(i, GetField(i)); - } - return output; - } - - internal string ConcatenateFields(int columnCount = RecordMap.MaxColumns - 1) - { - string output = string.Empty; - for (int i = 0; i < columnCount; i++) - { - output += '"' + GetField(i).Replace("\"", "\"\"") + "\","; - } - return output.TrimEnd(','); - } -} - -internal class RecordMap : ClassMap -{ - internal const int MaxColumns = 32; - internal RecordMap() - { - for (int i = 0; i < MaxColumns - 1; i++) - { - // Ultra thanks to David Specht on https://stackoverflow.com/a/62601123 - Map( - typeof(Record), - typeof(Record).GetProperty("Column" + i)) - .Optional() - .Index(i); - } - } -} diff --git a/rowsSharp/Internal/View/Common.xaml b/rowsSharp/Internal/View/Common.xaml deleted file mode 100644 index 2da5576..0000000 --- a/rowsSharp/Internal/View/Common.xaml +++ /dev/null @@ -1,412 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/rowsSharp/Internal/View/InitWizardWindow.xaml b/rowsSharp/Internal/View/InitWizardWindow.xaml deleted file mode 100644 index 76879f2..0000000 --- a/rowsSharp/Internal/View/InitWizardWindow.xaml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/rowsSharp/View/Editor.xaml.cs b/rowsSharp/View/Editor.xaml.cs new file mode 100644 index 0000000..fc5f62d --- /dev/null +++ b/rowsSharp/View/Editor.xaml.cs @@ -0,0 +1,14 @@ +using System.Windows.Controls; + +namespace rowsSharp.View; + +/// +/// Interaction logic for Home.xaml +/// +public partial class Editor : UserControl +{ + public Editor() + { + InitializeComponent(); + } +} diff --git a/rowsSharp/View/Font/LICENSE b/rowsSharp/View/Font/LICENSE new file mode 100644 index 0000000..7a4a3ea --- /dev/null +++ b/rowsSharp/View/Font/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/rowsSharp/View/Font/material-symbols-outlined.otf b/rowsSharp/View/Font/material-symbols-outlined.otf new file mode 100644 index 0000000..acb29e7 Binary files /dev/null and b/rowsSharp/View/Font/material-symbols-outlined.otf differ diff --git a/rowsSharp/Internal/View/Light.xaml b/rowsSharp/View/Light.xaml similarity index 70% rename from rowsSharp/Internal/View/Light.xaml rename to rowsSharp/View/Light.xaml index 7773023..f245267 100644 --- a/rowsSharp/Internal/View/Light.xaml +++ b/rowsSharp/View/Light.xaml @@ -1,48 +1,47 @@  - + 1400 600 - 1 - 25 - * + 0 + 33 + 2* + * Courier New,Roboto Mono 16 - 14 - - + 22 + 12 + + - + + + + + + - + - - - + + + + + - - - - - - - - - - - + diff --git a/rowsSharp/View/MainWindow.xaml b/rowsSharp/View/MainWindow.xaml new file mode 100644 index 0000000..2d471c4 --- /dev/null +++ b/rowsSharp/View/MainWindow.xaml @@ -0,0 +1,26 @@ + + + + + + + + + + diff --git a/rowsSharp/View/MainWindow.xaml.cs b/rowsSharp/View/MainWindow.xaml.cs new file mode 100644 index 0000000..6edfe97 --- /dev/null +++ b/rowsSharp/View/MainWindow.xaml.cs @@ -0,0 +1,17 @@ +using rowsSharp.ViewModel; +using System.Windows; + +namespace rowsSharp.View; + +/// +/// Interaction logic for MainWindow.xaml +/// +public partial class MainWindow : Window +{ + public MainWindow() + { + DataContext = new RootVM(); + InitializeComponent(); + App.Logger.Info("Okay, it's happening! Everybody stay calm!"); + } +} diff --git a/rowsSharp/View/RenameColumn.xaml b/rowsSharp/View/RenameColumn.xaml new file mode 100644 index 0000000..94ea48a --- /dev/null +++ b/rowsSharp/View/RenameColumn.xaml @@ -0,0 +1,21 @@ + + + + Enter new column name + + + + + + + + diff --git a/rowsSharp/View/RenameColumn.xaml.cs b/rowsSharp/View/RenameColumn.xaml.cs new file mode 100644 index 0000000..cdfc042 --- /dev/null +++ b/rowsSharp/View/RenameColumn.xaml.cs @@ -0,0 +1,27 @@ +using System.Windows; + +namespace rowsSharp.View +{ + // MVVM is an overkill here, hence not implemented. + + public partial class RenameColumn : Window + { + public RenameColumn() + { + InitializeComponent(); + } + + public string? NewName { get; set; } + + private void Rename_Click(object sender, RoutedEventArgs e) + { + NewName = ColumnName.Text; + DialogResult = true; + } + + private void Cancel_Click(object sender, RoutedEventArgs e) + { + DialogResult = false; + } + } +} diff --git a/rowsSharp/View/ResourceDictionary/Button.xaml b/rowsSharp/View/ResourceDictionary/Button.xaml new file mode 100644 index 0000000..e1bad4d --- /dev/null +++ b/rowsSharp/View/ResourceDictionary/Button.xaml @@ -0,0 +1,54 @@ + + + + + + \ No newline at end of file diff --git a/rowsSharp/View/ResourceDictionary/DataGrid.xaml b/rowsSharp/View/ResourceDictionary/DataGrid.xaml new file mode 100644 index 0000000..6bd2bf9 --- /dev/null +++ b/rowsSharp/View/ResourceDictionary/DataGrid.xaml @@ -0,0 +1,97 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/rowsSharp/View/ResourceDictionary/OptionMark.xaml b/rowsSharp/View/ResourceDictionary/OptionMark.xaml new file mode 100644 index 0000000..dbd0806 --- /dev/null +++ b/rowsSharp/View/ResourceDictionary/OptionMark.xaml @@ -0,0 +1,72 @@ + + + + + \ No newline at end of file diff --git a/rowsSharp/View/ResourceDictionary/Scrollbar.xaml b/rowsSharp/View/ResourceDictionary/Scrollbar.xaml new file mode 100644 index 0000000..adf5a60 --- /dev/null +++ b/rowsSharp/View/ResourceDictionary/Scrollbar.xaml @@ -0,0 +1,94 @@ + + + + + + + \ No newline at end of file diff --git a/rowsSharp/View/ResourceDictionary/TextBlock.xaml b/rowsSharp/View/ResourceDictionary/TextBlock.xaml new file mode 100644 index 0000000..073be25 --- /dev/null +++ b/rowsSharp/View/ResourceDictionary/TextBlock.xaml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/rowsSharp/View/ResourceDictionary/TextBox.xaml b/rowsSharp/View/ResourceDictionary/TextBox.xaml new file mode 100644 index 0000000..00dc979 --- /dev/null +++ b/rowsSharp/View/ResourceDictionary/TextBox.xaml @@ -0,0 +1,35 @@ + + + + + + \ No newline at end of file diff --git a/rowsSharp/View/ResourceDictionary/ToggleButton.xaml b/rowsSharp/View/ResourceDictionary/ToggleButton.xaml new file mode 100644 index 0000000..28fe5ac --- /dev/null +++ b/rowsSharp/View/ResourceDictionary/ToggleButton.xaml @@ -0,0 +1,58 @@ + + + + + \ No newline at end of file diff --git a/rowsSharp/View/Settings.xaml b/rowsSharp/View/Settings.xaml new file mode 100644 index 0000000..43f6ec9 --- /dev/null +++ b/rowsSharp/View/Settings.xaml @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + + + + + + + + + + +