From ec8150ad22c4dbf88690d81f7e640921381ec618 Mon Sep 17 00:00:00 2001 From: haruki-taka8 <77907336+haruki-taka8@users.noreply.github.com> Date: Sat, 4 Feb 2023 18:47:24 +0800 Subject: [PATCH] Initial commit --- .gitignore | 398 ++++++++++++++++++++++++++++++++++++++++ Core/Extension.cs | 10 + Core/History.cs | 105 +++++++++++ Core/ObservableStack.cs | 57 ++++++ Core/ObservableTable.cs | 200 ++++++++++++++++++++ IO/Export.cs | 30 +++ IO/Import.cs | 45 +++++ ObservableTable.csproj | 12 ++ ObservableTable.sln | 25 +++ 9 files changed, 882 insertions(+) create mode 100644 .gitignore create mode 100644 Core/Extension.cs create mode 100644 Core/History.cs create mode 100644 Core/ObservableStack.cs create mode 100644 Core/ObservableTable.cs create mode 100644 IO/Export.cs create mode 100644 IO/Import.cs create mode 100644 ObservableTable.csproj create mode 100644 ObservableTable.sln diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f3be99 --- /dev/null +++ b/.gitignore @@ -0,0 +1,398 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml \ No newline at end of file diff --git a/Core/Extension.cs b/Core/Extension.cs new file mode 100644 index 0000000..24f06e6 --- /dev/null +++ b/Core/Extension.cs @@ -0,0 +1,10 @@ +namespace ObservableTable.Core; + +internal static class Extension +{ + internal static IList PadRight(this IList list, int resultantLength) + { + var padding = Enumerable.Repeat(default, resultantLength - list.Count); + return list.Concat(padding).ToList(); + } +} diff --git a/Core/History.cs b/Core/History.cs new file mode 100644 index 0000000..1ef9e11 --- /dev/null +++ b/Core/History.cs @@ -0,0 +1,105 @@ +namespace ObservableTable.Core; + +internal enum Change +{ + Inline, + InsertRow, + RemoveRow, + InsertColumn, + RemoveColumn +} + +internal class Operation +{ + // Properties + #region Change, Index, Parity, Payloads + internal Change Change { get; set; } + internal int Index { get; init; } + internal bool Parity { get; init; } + + // Properties (Payloads: column, row, cell) + internal T? Header { get; init; } + internal IEnumerable? Column { get; init; } + internal IEnumerable? Row { get; init; } + internal T? Cell { get; set; } // set; since cell content can change on each undo/redo + internal int? CellIndex { get; init; } + #endregion Fields + + // Constructors + #region private Operation() + private Operation(Change change, int index, bool parity) + { + Change = change; + Index = index; + Parity = parity; + } + + /// + /// Use this constructor for insertion/removal of a column + /// + /// Must be Change.InsertColumn or Change.RemoveColumn + internal Operation(Change change, int index, bool parity, T header, IEnumerable column) : this(change, index, parity) + { + Header = header; + Column = column; + } + + /// + /// Use this constructor for insertion/removal of a row + /// + /// Must be Change.InsertRow or Change.RemoveRow + internal Operation(Change change, int index, bool parity, IList row) : this(change, index, parity) + { + Row = row; + } + + /// + /// Use this constructor for modification of a single cell + /// + /// Must be Change.Inline + /// The original value of the cell + /// An non-negative integer indicating the column number of the modified cell + internal Operation(Change change, int index, bool parity, T? cell, int? cellIndex) : this(change, index, parity) + { + Cell = cell; + CellIndex = cellIndex; + } + #endregion Constructors + + // Method + /// + /// Deep copies all fields, including null properties. + /// + /// + /// + /// The IClonable interface is not mentioned because of it does not show + /// whether the Clone() method carries out a shallow or a deep copy. + /// + /// + /// + /// A deep-copied instance of OperationPlus + /// + public Operation DeepCopy() + { + return new(Change, Index, Parity) + { + Header = Header, + Column = Column, + Row = Row, + Cell = Cell, + CellIndex = CellIndex + }; + } + + public void InvertChange() + { + Change = Change switch + { + Change.RemoveRow => Change.InsertRow, + Change.InsertRow => Change.RemoveRow, + Change.InsertColumn => Change.RemoveColumn, + Change.RemoveColumn => Change.InsertColumn, + _ => Change.Inline + }; + } +} diff --git a/Core/ObservableStack.cs b/Core/ObservableStack.cs new file mode 100644 index 0000000..86a86a8 --- /dev/null +++ b/Core/ObservableStack.cs @@ -0,0 +1,57 @@ +using System.Collections.Specialized; +using System.ComponentModel; + +namespace ObservableTable.Core; + +/// +/// ObservableStack<T> implementation by +/// Ernie S used under +/// CC BY 4.0 +/// / Refactored INotifyCollectionChanged and INotifyPropertyChanged implementations +/// + +internal class ObservableStack : Stack, INotifyCollectionChanged, INotifyPropertyChanged +{ + // Constructors + public ObservableStack() : base() { } + public ObservableStack(IEnumerable collection) : base(collection) { } + public ObservableStack(int capacity) : base(capacity) { } + + // Overrides + public new virtual T Pop() + { + var item = base.Pop(); + OnCollectionChanged(NotifyCollectionChangedAction.Remove, item); + return item; + } + + public new virtual void Push(T item) + { + base.Push(item); + OnCollectionChanged(NotifyCollectionChangedAction.Add, item); + } + + public new virtual void Clear() + { + base.Clear(); + OnCollectionChanged(NotifyCollectionChangedAction.Reset); + } + + // INotifyCollectionChanged + public event NotifyCollectionChangedEventHandler? CollectionChanged; + protected virtual void OnCollectionChanged(NotifyCollectionChangedAction action, T item) + { + CollectionChanged?.Invoke(this, new(action, item)); + PropertyChanged?.Invoke(this, new(nameof(Count))); + } + + protected virtual void OnCollectionChanged(NotifyCollectionChangedAction action) + { + if (action != NotifyCollectionChangedAction.Reset) { throw new ArgumentException("Reset only."); } + CollectionChanged?.Invoke(this, new(action)); + PropertyChanged?.Invoke(this, new(nameof(Count))); + } + + // INotifyPropertyChanged + public event PropertyChangedEventHandler? PropertyChanged; +} \ No newline at end of file diff --git a/Core/ObservableTable.cs b/Core/ObservableTable.cs new file mode 100644 index 0000000..c5901ab --- /dev/null +++ b/Core/ObservableTable.cs @@ -0,0 +1,200 @@ +using System.Collections.ObjectModel; +using System.Collections.Specialized; +namespace ObservableTable.Core; + +public class ObservableTable +{ + // Properties & Fields + public ObservableCollection> Records { get; } = new(); + public ObservableCollection Headers { get; init; } = new(); + + public int UndoCount => UndoStack.Count; + public int RedoCount => RedoStack.Count; + + private readonly ObservableStack> UndoStack = new(); + private readonly ObservableStack> RedoStack = new(); + private bool parity; + + // Constructors + public ObservableTable() { } + + public ObservableTable(IList headers, IEnumerable records) + { + Headers = new(headers); + + foreach (var record in records) + { + // Register CollectionChanged for each row + ObservableCollection toAdd = new(record.PadRight(Headers.Count)); + toAdd.CollectionChanged += RecordChanged; + Records.Add(toAdd); + } + } + + public ObservableTable(IEnumerable headers, IEnumerable records) : this(new List(headers), records) { } + + // Methods: Private + private void RecordChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.Action != NotifyCollectionChangedAction.Replace + || sender is null + || e.OldItems is null) + { return; } + + // Handles inline changes + var newRecord = (ObservableCollection)sender; + var index = Records.IndexOf(newRecord); + var oldCell = (T?)e.OldItems[0]; + UndoStack.Push(new(Change.Inline, index, parity, oldCell, e.OldStartingIndex)); + CommitHistory(); + } + + // Methods: Row/Column Modifications + public void InsertRow(int index, params IList[] items) + { + if (index < 0 || index > Records.Count - 1) { return; } + + foreach (var item in items.Reverse()) + { + IList baseToAdd = item.PadRight(Headers.Count); + ObservableCollection toAdd = new(baseToAdd); + toAdd.CollectionChanged += RecordChanged; + + Records.Insert(index, toAdd); + UndoStack.Push(new(Change.InsertRow, index, parity, baseToAdd)); + } + CommitHistory(); + } + + public void RemoveRow(params ObservableCollection[] items) + { + foreach (var item in items) + { + UndoStack.Push(new(Change.RemoveRow, Records.IndexOf(item), parity, item)); + Records.Remove(item); + } + CommitHistory(); + } + + public void InsertColumn(int index, params (T Header, IEnumerable Content)[] payload) + { + if (index > Headers.Count - 1 || index < 0) { return; } + + foreach (var (header, content) in payload) + { + InsertColumn(index, header, content); + UndoStack.Push(new(Change.InsertColumn, index, parity, header, content)); + index++; + } + CommitHistory(); + } + + private void InsertColumn(int index, T header, IEnumerable content) + { + for (int i = 0; i < Records.Count; i++) + { + Records[i].Insert(index, content.ElementAtOrDefault(i)); + } + Headers.Insert(index, header); + } + + public void RemoveColumn(params T[] headers) + { + foreach (var header in headers) + { + int index = Headers.IndexOf(header); + if (index == -1) { return; } + + var removedColumn = RemoveColumn(index); + UndoStack.Push(new(Change.RemoveColumn, index, parity, header, removedColumn)); + } + CommitHistory(); + } + + private IEnumerable RemoveColumn(int index) + { + // Remove header first to prevent binding failures + Headers.RemoveAt(index); + + List column = new(); + foreach (var record in Records) + { + column.Add(record[index]); + record.RemoveAt(index); + } + return column; + } + + // Methods: History + private void RevertHistory(Operation last) + { + switch (last.Change) + { + case Change.InsertRow: + if (last.Row is null) { break; } + Records.Insert(last.Index, new(last.Row)); + break; + + case Change.RemoveRow: + Records.RemoveAt(last.Index); + break; + + case Change.InsertColumn: + if (last.Header is null || last.Column is null) { return; } + InsertColumn(last.Index, last.Header, last.Column); + break; + + case Change.RemoveColumn: + RemoveColumn(last.Index); + break; + + case Change.Inline: + // Avoid pushing this change to UndoStack + Records[last.Index].CollectionChanged -= RecordChanged; + Records[last.Index][last.CellIndex ?? 0] = last.Cell; + Records[last.Index].CollectionChanged += RecordChanged; + break; + } + } + + private Operation UpdateCellInOperation(Operation operation) + { + var output = operation.DeepCopy(); + if (output.Change == Change.Inline) + { + output.Cell = Records[output.Index][output.CellIndex ?? 0]; + } + return output; + } + + public void Undo() + { + if (UndoStack.Count == 0) { return; } + Operation last = UndoStack.Pop(); + RedoStack.Push(UpdateCellInOperation(last)); + + last.InvertChange(); + RevertHistory(last); + + if (UndoStack.Count > 0 && last.Parity == UndoStack.Peek().Parity) + { Undo(); } + } + + public void Redo() + { + if (RedoStack.Count == 0) { return; } + Operation last = RedoStack.Pop(); + UndoStack.Push(UpdateCellInOperation(last)); + + RevertHistory(last); + + if (RedoStack.Count > 0 && last.Parity == RedoStack.Peek().Parity) + { Redo(); } + } + + private void CommitHistory() + { + RedoStack.Clear(); + parity = !parity; + } +} diff --git a/IO/Export.cs b/IO/Export.cs new file mode 100644 index 0000000..8ca4a9e --- /dev/null +++ b/IO/Export.cs @@ -0,0 +1,30 @@ +using ObservableTable.Core; + +namespace ObservableTable.IO; + +public static class Exporter +{ + private static string ConcatenateList(IList list) + { + return '"' + string.Join("\",\"", list) + '"'; + } + + public static string ToCsvString(ObservableTable table, bool hasHeader = true) + { + string result = hasHeader ? ConcatenateList((IList)table.Headers) : ""; + + foreach (var record in table.Records) + { + result += Environment.NewLine + ConcatenateList(record); + } + return result; + } + + public static void ToFile(string path, ObservableTable table, bool hasHeader = true) + { + string csvString = ToCsvString(table, hasHeader); + + using StreamWriter writer = new(path); + writer.WriteLine(csvString); + } +} diff --git a/IO/Import.cs b/IO/Import.cs new file mode 100644 index 0000000..5892bf1 --- /dev/null +++ b/IO/Import.cs @@ -0,0 +1,45 @@ +using CsvHelper; +using CsvHelper.Configuration; +using System.Globalization; +using ObservableTable.Core; + +namespace ObservableTable.IO; + +public static class Importer +{ + private static readonly CsvConfiguration configuration = new(CultureInfo.InvariantCulture) + { + MissingFieldFound = null, + BadDataFound = null + }; + + private static List GetRecords(CsvReader csvReader) + { + List records = new(); + while (csvReader.Read()) + { + var thisRow = csvReader.Parser.Record; + records.Add(thisRow); + } + return records; + } + + private static IEnumerable GetHeader(string?[] firstRecord, bool hasHeader = true) + { + return hasHeader + ? firstRecord.Select(x => x ?? "") + : Enumerable.Range(0, firstRecord.Length).Select(x => x.ToString()); + } + + public static ObservableTable FromFilePath(string filePath, bool hasHeader = true) + { + using StreamReader streamReader = new(filePath); + using CsvReader csvReader = new(streamReader, configuration); + + var records = GetRecords(csvReader); + var headers = GetHeader(records[0], hasHeader); + if (hasHeader) { records.RemoveAt(0); } + + return new ObservableTable(headers, records); + } +} diff --git a/ObservableTable.csproj b/ObservableTable.csproj new file mode 100644 index 0000000..c721014 --- /dev/null +++ b/ObservableTable.csproj @@ -0,0 +1,12 @@ + + + + net6.0 + enable + enable + + + + + + diff --git a/ObservableTable.sln b/ObservableTable.sln new file mode 100644 index 0000000..bf0ce7a --- /dev/null +++ b/ObservableTable.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.4.33110.190 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ObservableTable", "ObservableTable.csproj", "{354B6679-BECE-451D-910E-2B2F09CECA79}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {354B6679-BECE-451D-910E-2B2F09CECA79}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {354B6679-BECE-451D-910E-2B2F09CECA79}.Debug|Any CPU.Build.0 = Debug|Any CPU + {354B6679-BECE-451D-910E-2B2F09CECA79}.Release|Any CPU.ActiveCfg = Release|Any CPU + {354B6679-BECE-451D-910E-2B2F09CECA79}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {BABA45B4-2748-4C2E-9D56-0E26B45AF046} + EndGlobalSection +EndGlobal