diff --git a/README-V2.md b/README-V2.md index 89229e2e..3f77c5af 100644 --- a/README-V2.md +++ b/README-V2.md @@ -342,7 +342,7 @@ var excelImporter = MiniExcel.Importers.GetOpenXmlImporter(); var sheetNames = excelImporter.GetSheetNames(path); ``` -#### 6. Get the columns' names from an Excel sheet +#### 6. Get the columns' names from an Excel worksheet ```csharp var excelImporter = MiniExcel.Importers.GetOpenXmlImporter(); @@ -350,9 +350,39 @@ var columns = excelImporter.GetColumnNames(path); // columns = [ColumnName1, ColumnName2, ...] when there is a header row // columns = ["A","B",...] otherwise + +``` +#### 7. Retrieve Comments from an Excel worksheet + +You can extract threaded comments and their replies from a worksheet using the `RetrieveComments` method: + +```csharp +var excelImporter = MiniExcel.Importers.GetOpenXmlImporter(); +var comments = excelImporter.RetrieveComments(path, sheetName: "Sheet1").Comments; + +foreach (var comment in comments) +{ + Console.WriteLine($"Cell: {comment.CellAddress}"); + Console.WriteLine($"{comment.CreatedAt:yy-MM-dd HH:mm}, {comment.Author.DisplayName}: {comment.Text}"); + + foreach (var reply in comment.Replies) + { + Console.WriteLine($"{reply.CreatedAt:yy-MM-dd HH:mm}, {reply.Author.DisplayName}: {reply.Text}"); + } +} +``` + +You can similarly retrieve notes as well: +```csharp +var notes = excelImporter.RetrieveComments(path, sheetName: "Sheet1").Notes; +foreach (var note in notes) +{ + Console.WriteLine($"Cell: {note.CellAddress}"); + Console.WriteLine($"{note.Author.DisplayName}: {note.Text}"); +} ``` -#### 7. Casting dynamic rows to IDictionary +#### 8. Casting dynamic rows to IDictionary Under the hood the dynamic objects returned in a query are implemented using `ExpandoObject`, making it possible to cast them to `IDictionary`: @@ -370,7 +400,7 @@ foreach(IDictionary row in excelImporter.Query(path)) } ``` -#### 8. Query Excel sheet as a DataTable +#### 9. Query Excel worksheet as a DataTable This is not recommended, as `DataTable` will forcibly load all data into memory, effectively losing the advantages MiniExcel offers. @@ -379,7 +409,7 @@ var excelImporter = MiniExcel.Importers.GetOpenXmlImporter(); var table = excelImporter.QueryAsDataTable(path); ``` -#### 9. Specify what cell to start reading data from +#### 10. Specify what cell to start reading data from ```csharp var excelImporter = MiniExcel.Importers.GetOpenXmlImporter(); @@ -387,7 +417,7 @@ excelImporter.Query(path, startCell: "B3") ``` ![image](https://user-images.githubusercontent.com/12729184/117260316-8593c400-ae81-11eb-9877-c087b7ac2b01.png) -#### 10. Fill Merged Cells +#### 11. Fill Merged Cells If the Excel sheet being queried contains merged cells it is possble to enable the option to fill every row with the merged value. @@ -410,7 +440,7 @@ Filling of cells with variable width and height is also supported >Note: The performance will take a hit when enabling the feature. >This happens because in the OpenXml standard the `mergeCells` are indicated at the bottom of the file, which leads to the need of reading the whole sheet twice. -#### 11. Big files and disk-based cache +#### 12. Big files and disk-based cache If the SharedStrings file size exceeds 5 MB, MiniExcel will default to use a local disk cache. E.g: on the file [10x100000.xlsx](https://github.com/MiniExcel/MiniExcel/files/8403819/NotDuplicateSharedStrings_10x100000.xlsx) (one million rows of data), when disabling the disk cache the maximum memory usage is 195 MB, but with disk cache enabled only 65 MB of memory are used. diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 8c3973a8..b152565c 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -1,6 +1,6 @@ - + diff --git a/src/MiniExcel.Core/WriteAdapters/DataReaderWriteAdapter.cs b/src/MiniExcel.Core/WriteAdapters/DataReaderWriteAdapter.cs index 952e246c..cc100b7c 100644 --- a/src/MiniExcel.Core/WriteAdapters/DataReaderWriteAdapter.cs +++ b/src/MiniExcel.Core/WriteAdapters/DataReaderWriteAdapter.cs @@ -18,7 +18,7 @@ public List GetColumns() { var columnName = _reader.GetName(i); if (!_configuration.DynamicColumnFirst || - _configuration.DynamicColumns.Any(d => string.Equals(d.Key, columnName, StringComparison.OrdinalIgnoreCase))) + _configuration.DynamicColumns?.Any(d => string.Equals(d.Key, columnName, StringComparison.OrdinalIgnoreCase)) is true) { var map = ColumnMappingsProvider.GetColumnMappingFromDynamicConfiguration(columnName, _configuration); mappings.Add(map); diff --git a/src/MiniExcel.OpenXml/Api/OpenXmlExporter.cs b/src/MiniExcel.OpenXml/Api/OpenXmlExporter.cs index ed8a8810..7951ca28 100644 --- a/src/MiniExcel.OpenXml/Api/OpenXmlExporter.cs +++ b/src/MiniExcel.OpenXml/Api/OpenXmlExporter.cs @@ -18,7 +18,12 @@ public async Task InsertSheetAsync(string path, object value, string? sheet return rowsWritten.FirstOrDefault(); } +#if NET8_0_OR_GREATER + var stream = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.Read, 4096, FileOptions.SequentialScan); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.Read, 4096, FileOptions.SequentialScan); +#endif return await InsertSheetAsync(stream, value, sheetName, printHeader, overwriteSheet, configuration, progress, cancellationToken).ConfigureAwait(false); } @@ -46,8 +51,13 @@ public async Task ExportAsync(string path, object value, bool printHeader throw new NotSupportedException("MiniExcel's Export does not support the .xlsm format"); var filePath = path.EndsWith(".xlsx", StringComparison.InvariantCultureIgnoreCase) ? path : $"{path}.xlsx" ; - + +#if NET8_0_OR_GREATER + var stream = overwriteFile ? File.Create(filePath) : new FileStream(filePath, FileMode.CreateNew); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = overwriteFile ? File.Create(filePath) : new FileStream(filePath, FileMode.CreateNew); +#endif return await ExportAsync(stream, value, printHeader, sheetName, configuration, progress, cancellationToken).ConfigureAwait(false); } diff --git a/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs b/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs index 17c6ee16..535602a4 100644 --- a/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs +++ b/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs @@ -1,4 +1,3 @@ -using System.Dynamic; using MiniExcelLib.OpenXml; using OpenXmlReader = MiniExcelLib.OpenXml.OpenXmlReader; @@ -16,11 +15,14 @@ public async IAsyncEnumerable QueryAsync(string path, string? sheetName = string startCell = "A1", bool treatHeaderAsData = false, OpenXmlConfiguration? configuration = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) where T : class, new() { +#if NET8_0_OR_GREATER + var stream = FileHelper.OpenSharedRead(path); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = FileHelper.OpenSharedRead(path); - +#endif var query = QueryAsync(stream, sheetName, startCell, treatHeaderAsData, configuration, cancellationToken); - //Foreach yield return twice reason : https://stackoverflow.com/questions/66791982/ienumerable-extract-code-lazy-loading-show-stream-was-not-readable await foreach (var item in query.ConfigureAwait(false)) yield return item; } @@ -40,7 +42,13 @@ public async IAsyncEnumerable QueryAsync(string path, bool useHeaderRow string? sheetName = null, string startCell = "A1", OpenXmlConfiguration? configuration = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { +#if NET8_0_OR_GREATER + var stream = FileHelper.OpenSharedRead(path); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = FileHelper.OpenSharedRead(path); +#endif + await foreach (var item in QueryAsync(stream, useHeaderRow, sheetName, startCell, configuration, cancellationToken).ConfigureAwait(false)) yield return item; } @@ -72,7 +80,12 @@ public async IAsyncEnumerable QueryRangeAsync(string path, bool useHead string? sheetName = null, string startCell = "A1", string endCell = "", OpenXmlConfiguration? configuration = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { +#if NET8_0_OR_GREATER + var stream = FileHelper.OpenSharedRead(path); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = FileHelper.OpenSharedRead(path); +#endif await foreach (var item in QueryRangeAsync(stream, useHeaderRow, sheetName, startCell, endCell, configuration, cancellationToken).ConfigureAwait(false)) yield return item; } @@ -93,7 +106,12 @@ public async IAsyncEnumerable QueryRangeAsync(string path, bool useHead int? endColumnIndex = null, OpenXmlConfiguration? configuration = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { +#if NET8_0_OR_GREATER + var stream = FileHelper.OpenSharedRead(path); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = FileHelper.OpenSharedRead(path); +#endif await foreach (var item in QueryRangeAsync(stream, useHeaderRow, sheetName, startRowIndex, startColumnIndex, endRowIndex, endColumnIndex, configuration, cancellationToken).ConfigureAwait(false)) yield return item; } @@ -121,7 +139,12 @@ public async Task QueryAsDataTableAsync(string path, bool useHeaderRo string? sheetName = null, string startCell = "A1", OpenXmlConfiguration? configuration = null, CancellationToken cancellationToken = default) { +#if NET8_0_OR_GREATER + var stream = FileHelper.OpenSharedRead(path); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = FileHelper.OpenSharedRead(path); +#endif return await QueryAsDataTableAsync(stream, useHeaderRow, sheetName, startCell, configuration, cancellationToken).ConfigureAwait(false); } @@ -133,7 +156,6 @@ public async Task QueryAsDataTableAsync(Stream stream, bool useHeader string? sheetName = null, string startCell = "A1", OpenXmlConfiguration? configuration = null, CancellationToken cancellationToken = default) { - /*Issue #279*/ sheetName ??= (await GetSheetNamesAsync(stream, configuration, cancellationToken).ConfigureAwait(false)).First(); var dt = new DataTable(sheetName); @@ -187,7 +209,12 @@ public async Task QueryAsDataTableAsync(Stream stream, bool useHeader [CreateSyncVersion] public async Task> GetSheetNamesAsync(string path, OpenXmlConfiguration? config = null, CancellationToken cancellationToken = default) { +#if NET8_0_OR_GREATER + var stream = FileHelper.OpenSharedRead(path); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = FileHelper.OpenSharedRead(path); +#endif return await GetSheetNamesAsync(stream, config, cancellationToken).ConfigureAwait(false); } @@ -207,7 +234,12 @@ public async Task> GetSheetNamesAsync(Stream stream, OpenXmlConfigu [CreateSyncVersion] public async Task> GetSheetInformationsAsync(string path, OpenXmlConfiguration? config = null, CancellationToken cancellationToken = default) { +#if NET8_0_OR_GREATER + var stream = FileHelper.OpenSharedRead(path); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = FileHelper.OpenSharedRead(path); +#endif return await GetSheetInformationsAsync(stream, config, cancellationToken).ConfigureAwait(false); } @@ -226,7 +258,12 @@ public async Task> GetSheetInformationsAsync(Stream stream, Open [CreateSyncVersion] public async Task> GetSheetDimensionsAsync(string path, CancellationToken cancellationToken = default) { +#if NET8_0_OR_GREATER + var stream = FileHelper.OpenSharedRead(path); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = FileHelper.OpenSharedRead(path); +#endif return await GetSheetDimensionsAsync(stream, cancellationToken).ConfigureAwait(false); } @@ -242,7 +279,12 @@ public async Task> GetColumnNamesAsync(string path, bool use string? sheetName = null, string startCell = "A1", OpenXmlConfiguration? configuration = null, CancellationToken cancellationToken = default) { +#if NET8_0_OR_GREATER + var stream = FileHelper.OpenSharedRead(path); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = FileHelper.OpenSharedRead(path); +#endif return await GetColumnNamesAsync(stream, useHeaderRow, sheetName, startCell, configuration, cancellationToken).ConfigureAwait(false); } @@ -260,6 +302,25 @@ public async Task> GetColumnNamesAsync(Stream stream, bool u return []; } + [CreateSyncVersion] + public async Task RetrieveCommentsAsync(string path, string? sheetName, CancellationToken cancellationToken = default) + { +#if NET8_0_OR_GREATER + var stream = FileHelper.OpenSharedRead(path); + await using var disposableStream = stream.ConfigureAwait(false); +#else + using var stream = FileHelper.OpenSharedRead(path); +#endif + return await RetrieveCommentsAsync(stream, sheetName, cancellationToken).ConfigureAwait(false); + } + + [CreateSyncVersion] + public async Task RetrieveCommentsAsync(Stream stream, string? sheetName, CancellationToken cancellationToken = default) + { + using var reader = await OpenXmlReader.CreateAsync(stream, null, cancellationToken).ConfigureAwait(false); + return await reader.ReadCommentsAsync(sheetName, cancellationToken).ConfigureAwait(false); + } + #endregion #region DataReader diff --git a/src/MiniExcel.OpenXml/Api/OpenXmlTemplater.cs b/src/MiniExcel.OpenXml/Api/OpenXmlTemplater.cs index 9b4e2aca..e6fcc435 100644 --- a/src/MiniExcel.OpenXml/Api/OpenXmlTemplater.cs +++ b/src/MiniExcel.OpenXml/Api/OpenXmlTemplater.cs @@ -13,7 +13,12 @@ internal OpenXmlTemplater() { } [CreateSyncVersion] public async Task AddPictureAsync(string path, CancellationToken cancellationToken = default, params MiniExcelPicture[] images) { +#if NET8_0_OR_GREATER + var stream = File.Open(path, FileMode.OpenOrCreate); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = File.Open(path, FileMode.OpenOrCreate); +#endif await MiniExcelPictureImplement.AddPictureAsync(stream, cancellationToken, images).ConfigureAwait(false); } @@ -27,7 +32,12 @@ public async Task AddPictureAsync(Stream excelStream, CancellationToken cancella public async Task FillTemplateAsync(string path, string templatePath, object value, bool overwriteFile = false, OpenXmlConfiguration? configuration = null, CancellationToken cancellationToken = default) { +#if NET8_0_OR_GREATER + var stream = overwriteFile ? File.Create(path) : File.Open(path, FileMode.CreateNew); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = overwriteFile ? File.Create(path) : File.Open(path, FileMode.CreateNew); +#endif await FillTemplateAsync(stream, templatePath, value, configuration, cancellationToken).ConfigureAwait(false); } @@ -35,7 +45,13 @@ public async Task FillTemplateAsync(string path, string templatePath, object val public async Task FillTemplateAsync(string path, Stream templateStream, object value, bool overwriteFile = false, OpenXmlConfiguration? configuration = null, CancellationToken cancellationToken = default) { +#if NET8_0_OR_GREATER + var stream = overwriteFile ? File.Create(path) : File.Open(path, FileMode.CreateNew); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = overwriteFile ? File.Create(path) : File.Open(path, FileMode.CreateNew); +#endif + var template = GetOpenXmlTemplate(stream, configuration); await template.SaveAsByTemplateAsync(templateStream, value, cancellationToken).ConfigureAwait(false); } @@ -60,7 +76,12 @@ public async Task FillTemplateAsync(Stream stream, Stream templateStream, object public async Task FillTemplateAsync(string path, byte[] templateBytes, object value, bool overwriteFile = false, OpenXmlConfiguration? configuration = null, CancellationToken cancellationToken = default) { +#if NET8_0_OR_GREATER + var stream = overwriteFile ? File.Create(path) : File.Open(path, FileMode.CreateNew); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = overwriteFile ? File.Create(path) : File.Open(path, FileMode.CreateNew); +#endif await FillTemplateAsync(stream, templateBytes, value, configuration, cancellationToken).ConfigureAwait(false); } @@ -78,7 +99,12 @@ public async Task FillTemplateAsync(Stream stream, byte[] templateBytes, object public async Task MergeSameCellsAsync(string mergedFilePath, string path, OpenXmlConfiguration? configuration = null, CancellationToken cancellationToken = default) { +#if NET8_0_OR_GREATER + var stream = File.Create(mergedFilePath); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = File.Create(mergedFilePath); +#endif await MergeSameCellsAsync(stream, path, configuration, cancellationToken).ConfigureAwait(false); } diff --git a/src/MiniExcel.OpenXml/Constants/Schemas.cs b/src/MiniExcel.OpenXml/Constants/Schemas.cs index 72c2e08f..35f3f079 100644 --- a/src/MiniExcel.OpenXml/Constants/Schemas.cs +++ b/src/MiniExcel.OpenXml/Constants/Schemas.cs @@ -2,9 +2,16 @@ internal static class Schemas { - public const string SpreadsheetmlXmlns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; - public const string SpreadsheetmlXmlStrictns = "http://purl.oclc.org/ooxml/spreadsheetml/main"; - public const string SpreadsheetmlXmlRelationshipns = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; - public const string SpreadsheetmlXmlStrictRelationshipns = "http://purl.oclc.org/ooxml/officeDocument/relationships"; + public const string SpreadsheetmlXmlNs = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + public const string SpreadsheetmlXmlStrictNs = "http://purl.oclc.org/ooxml/spreadsheetml/main"; + + public const string OpenXmlPackageRelationships = "http://schemas.openxmlformats.org/package/2006/relationships"; + public const string SpreadsheetmlXmlRelationships = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + public const string SpreadsheetmlXmlStrictRelationships = "http://purl.oclc.org/ooxml/officeDocument/relationships"; + public const string SpreadsheetmlXmlComments = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"; + public const string SpreadsheetmlXmlThreadedComment = "http://schemas.microsoft.com/office/2017/10/relationships/threadedComment"; + public const string SpreadsheetmlXmlX14Ac = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/ac"; -} \ No newline at end of file + public const string SpreadsheetmlXmlX18Tc = "http://schemas.microsoft.com/office/spreadsheetml/2018/threadedcomments"; + public const string SpreadsheetmlXmlX14R = "http://schemas.microsoft.com/office/spreadsheetml/2014/revision"; +} diff --git a/src/MiniExcel.OpenXml/Models/Comments.cs b/src/MiniExcel.OpenXml/Models/Comments.cs new file mode 100644 index 00000000..37516727 --- /dev/null +++ b/src/MiniExcel.OpenXml/Models/Comments.cs @@ -0,0 +1,52 @@ +namespace MiniExcelLib.OpenXml.Models; + +public class CommentResultSet +{ + internal CommentResultSet(string sheetName, List comments, List notes) + { + SheetName = sheetName; + Comments = comments; + Notes = notes; + } + + public string SheetName { get; } + public IReadOnlyList Comments { get; } + public IReadOnlyList Notes { get; } +} + +public class ThreadedComment +{ + public Guid Id { get; internal set; } + public string ReferenceCell { get; internal set; } = null!; + public Author? Author { get; internal set; } + public bool Resolved { get; internal set; } + public string? Text { get; internal set; } + public DateTime CreatedAt { get; internal set; } + + internal List ThreadedComments = []; + public IReadOnlyList Replies => ThreadedComments; +} + +public class ThreadedCommentReply +{ + public Guid Id { get; internal set; } + public Guid? ParentId { get; internal set; } + public Author? Author { get; internal set; } + public DateTime CreatedAt { get; internal set; } + public string? Text { get; internal set; } +} + +public class NoteComment +{ + public Guid Id { get; internal set; } + public string? ReferenceCell { get; internal set; } + public string? Author { get; internal set; } + public string? Text { get; internal set; } +} + +public class Author +{ + public Guid Id { get; internal set; } + public string DisplayName { get; internal set; } = ""; + public string? ProviderId { get; internal set; } +} diff --git a/src/MiniExcel.OpenXml/Models/SheetRecord.cs b/src/MiniExcel.OpenXml/Models/SheetRecord.cs index 57747254..dded142a 100644 --- a/src/MiniExcel.OpenXml/Models/SheetRecord.cs +++ b/src/MiniExcel.OpenXml/Models/SheetRecord.cs @@ -6,7 +6,7 @@ internal sealed class SheetRecord(string name, string state, uint id, string rid public string State { get; set; } = state; public uint Id { get; } = id; public string Rid { get; set; } = rid; - public string Path { get; set; } + public string? Path { get; set; } public bool Active { get; } = active; public SheetInfo ToSheetInfo(uint index) diff --git a/src/MiniExcel.OpenXml/OpenXmlReader.cs b/src/MiniExcel.OpenXml/OpenXmlReader.cs index d9c23796..2313cc6a 100644 --- a/src/MiniExcel.OpenXml/OpenXmlReader.cs +++ b/src/MiniExcel.OpenXml/OpenXmlReader.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using System.Xml.Linq; using MiniExcelLib.Core; using MiniExcelLib.OpenXml.Constants; using MiniExcelLib.OpenXml.Styles; @@ -9,8 +10,8 @@ namespace MiniExcelLib.OpenXml; internal partial class OpenXmlReader : IMiniExcelReader { - private static readonly string[] Ns = [Schemas.SpreadsheetmlXmlns, Schemas.SpreadsheetmlXmlStrictns]; - private static readonly string[] RelationshiopNs = [Schemas.SpreadsheetmlXmlRelationshipns, Schemas.SpreadsheetmlXmlStrictRelationshipns]; + private static readonly string[] Ns = [Schemas.SpreadsheetmlXmlNs, Schemas.SpreadsheetmlXmlStrictNs]; + private static readonly string[] RelationshiopNs = [Schemas.SpreadsheetmlXmlRelationships, Schemas.SpreadsheetmlXmlStrictRelationships]; private readonly OpenXmlConfiguration _config; private List? _sheetRecords; @@ -90,9 +91,7 @@ internal static async Task CreateAsync(Stream stream, IMiniExcelC { var query = QueryRangeAsync(false, sheetName, startCell, endCell, cancellationToken); if (!CellReferenceConverter.TryParseCellReference(startCell, out _, out var rowOffset)) - { throw new InvalidDataException($"Value {startCell} is not a valid cell reference."); - } return MiniExcelMapper.MapQueryAsync(query, rowOffset, treatHeaderAsData, _config.TrimColumnNames, _config, XmlHelper.DecodeString, cancellationToken); } @@ -178,7 +177,11 @@ internal static async Task CreateAsync(Stream stream, IMiniExcelC } #if NET10_0_OR_GREATER - using var sheetStream = await sheetEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + var sheetStream = await sheetEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableSheetStream = sheetStream.ConfigureAwait(false); +#elif !NETSTANDARD2_0 + var sheetStream = sheetEntry.Open(); + await using var disposableSheetStream = sheetStream.ConfigureAwait(false); #else using var sheetStream = sheetEntry.Open(); #endif @@ -373,7 +376,7 @@ private ZipArchiveEntry GetSheetEntry(string? sheetName) var s = _sheetRecords[0]; sheetEntry = sheets.Single(w => w.FullName == $"xl/{s.Path}" || w.FullName == $"/xl/{s.Path}" || - w.FullName.TrimStart('/') == s.Path.TrimStart('/')); + w.FullName.TrimStart('/') == s.Path?.TrimStart('/')); } else { @@ -425,7 +428,11 @@ private async Task SetSharedStringsAsync(CancellationToken cancellationToken = d var idx = 0; #if NET10_0_OR_GREATER - using var stream = await sharedStringsEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + var stream = await sharedStringsEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableStream = stream.ConfigureAwait(false); +#elif !NETSTANDARD2_0 + var stream = sharedStringsEntry.Open(); + await using var disposableStream = stream.ConfigureAwait(false); #else using var stream = sharedStringsEntry.Open(); #endif @@ -453,7 +460,7 @@ private void SetWorkbookRels(ReadOnlyCollection entries) } [CreateSyncVersion] - internal static async IAsyncEnumerable ReadWorkbookAsync(ReadOnlyCollection entries, [EnumeratorCancellation] CancellationToken cancellationToken = default) + private static async IAsyncEnumerable ReadWorkbookAsync(ReadOnlyCollection entries, [EnumeratorCancellation] CancellationToken cancellationToken = default) { var xmlSettings = XmlReaderHelper.GetXmlReaderSettings( #if SYNC_ONLY @@ -465,7 +472,11 @@ internal static async IAsyncEnumerable ReadWorkbookAsync(ReadOnlyCo var entry = entries.Single(w => w.FullName == "xl/workbook.xml"); #if NET10_0_OR_GREATER - using var stream = await entry.OpenAsync(cancellationToken).ConfigureAwait(false); + var stream = await entry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableStream = stream.ConfigureAwait(false); +#elif !NETSTANDARD2_0 + var stream = entry.Open(); + await using var disposableStream = stream.ConfigureAwait(false); #else using var stream = entry.Open(); #endif @@ -561,20 +572,24 @@ await reader.SkipAsync() var entry = entries.Single(w => w.FullName == "xl/_rels/workbook.xml.rels"); #if NET10_0_OR_GREATER - using var stream = await entry.OpenAsync(cancellationToken).ConfigureAwait(false); + var stream = await entry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableStream = stream.ConfigureAwait(false); +#elif !NETSTANDARD2_0 + var stream = entry.Open(); + await using var disposableStream = stream.ConfigureAwait(false); #else using var stream = entry.Open(); #endif using var reader = XmlReader.Create(stream, xmlSettings); - if (!XmlReaderHelper.IsStartElement(reader, "Relationships", "http://schemas.openxmlformats.org/package/2006/relationships")) + if (!XmlReaderHelper.IsStartElement(reader, "Relationships", Schemas.OpenXmlPackageRelationships)) return null; if (!await XmlReaderHelper.ReadFirstContentAsync(reader, cancellationToken).ConfigureAwait(false)) return null; while (!reader.EOF) { - if (XmlReaderHelper.IsStartElement(reader, "Relationship", "http://schemas.openxmlformats.org/package/2006/relationships")) + if (XmlReaderHelper.IsStartElement(reader, "Relationship", Schemas.OpenXmlPackageRelationships)) { var rid = reader.GetAttribute("Id"); foreach (var sheet in sheetRecords.Where(sh => sh.Rid == rid)) @@ -766,11 +781,15 @@ internal async Task> GetDimensionsAsync(CancellationToken canc var withoutCr = false; #if NET10_0_OR_GREATER - using (var sheetStream = await sheet.OpenAsync(cancellationToken).ConfigureAwait(false)) + var crSheetStream = await sheet.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableCrSheetStream = crSheetStream.ConfigureAwait(false); +#elif !NETSTANDARD2_0 + var crSheetStream = sheet.Open(); + await using var disposableCrSheetStream = crSheetStream.ConfigureAwait(false); #else - using (var sheetStream = sheet.Open()) + using var crSheetStream = sheet.Open(); #endif - using (var reader = XmlReader.Create(sheetStream, xmlSettings)) + using (var reader = XmlReader.Create(crSheetStream, xmlSettings)) { while (await reader.ReadAsync().ConfigureAwait(false)) { @@ -818,7 +837,11 @@ internal async Task> GetDimensionsAsync(CancellationToken canc if (withoutCr) { #if NET10_0_OR_GREATER - using var sheetStream = await sheet.OpenAsync(cancellationToken).ConfigureAwait(false); + var sheetStream = await sheet.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableSheetStream = sheetStream.ConfigureAwait(false); +#elif !NETSTANDARD2_0 + var sheetStream = sheet.Open(); + await using var disposableSheetStream = sheetStream.ConfigureAwait(false); #else using var sheetStream = sheet.Open(); #endif @@ -915,12 +938,17 @@ internal static async Task TryGetMaxRowColumnIndexAs bool withoutCr = false; int maxRowIndex = -1; int maxColumnIndex = -1; + #if NET10_0_OR_GREATER - using (var sheetStream = await sheetEntry.OpenAsync(cancellationToken).ConfigureAwait(false)) + var crSheetStream = await sheetEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableCrSheetStream = crSheetStream.ConfigureAwait(false); +#elif !NETSTANDARD2_0 + var crSheetStream = sheetEntry.Open(); + await using var disposableCrSheetStream = crSheetStream.ConfigureAwait(false); #else - using (var sheetStream = sheetEntry.Open()) + using var crSheetStream = sheetEntry.Open(); #endif - using (var reader = XmlReader.Create(sheetStream, xmlSettings)) + using (var reader = XmlReader.Create(crSheetStream, xmlSettings)) { while (await reader.ReadAsync() #if NET6_0_OR_GREATER @@ -970,7 +998,11 @@ internal static async Task TryGetMaxRowColumnIndexAs if (withoutCr) { #if NET10_0_OR_GREATER - using var sheetStream = await sheetEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + var sheetStream = await sheetEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableSheetStream = sheetStream.ConfigureAwait(false); +#elif !NETSTANDARD2_0 + var sheetStream = sheetEntry.Open(); + await using var disposableSheetStream = sheetStream.ConfigureAwait(false); #else using var sheetStream = sheetEntry.Open(); #endif @@ -978,6 +1010,7 @@ internal static async Task TryGetMaxRowColumnIndexAs if (!XmlReaderHelper.IsStartElement(reader, "worksheet", Ns)) return new GetMaxRowColumnIndexResult(false); + if (!await XmlReaderHelper.ReadFirstContentAsync(reader, cancellationToken).ConfigureAwait(false)) return new GetMaxRowColumnIndexResult(false); @@ -1048,7 +1081,11 @@ internal static async Task TryGetMergeCellsAsync(ZipArchiveEntry sheetEntr var mergeCells = new MergeCells(); #if NET10_0_OR_GREATER - using var sheetStream = await sheetEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + var sheetStream = await sheetEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableSheetStream = sheetStream.ConfigureAwait(false); +#elif !NETSTANDARD2_0 + var sheetStream = sheetEntry.Open(); + await using var disposableSheetStream = sheetStream.ConfigureAwait(false); #else using var sheetStream = sheetEntry.Open(); #endif @@ -1104,9 +1141,184 @@ internal static async Task TryGetMergeCellsAsync(ZipArchiveEntry sheetEntr return true; } - ~OpenXmlReader() + [CreateSyncVersion] + internal async Task ReadCommentsAsync(string? sheetName, CancellationToken cancellationToken = default) { - Dispose(false); + if (string.IsNullOrEmpty(sheetName)) + throw new ArgumentException("sheetName cannot be null or empty", nameof(sheetName)); + + XNamespace nsRel = Schemas.OpenXmlPackageRelationships; + XNamespace ns18Tc = Schemas.SpreadsheetmlXmlX18Tc; + XNamespace nsMain = Schemas.SpreadsheetmlXmlNs; + XNamespace ns14R = Schemas.SpreadsheetmlXmlX14R; + + SetWorkbookRels(Archive.EntryCollection); + var sheetRecord = _sheetRecords?.SingleOrDefault(s => s.Name.Equals(sheetName, StringComparison.CurrentCultureIgnoreCase)); + if (sheetRecord?.Path?.Split('/')[^1] is not { } sheetFile) + throw new InvalidDataException($"There is no sheet named {sheetName}"); + + List people = []; + if (Archive.GetEntry("xl/persons/person.xml") is { } persons) + { +#if NET10_0_OR_GREATER + var personStream = await persons.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposablePersonStream = personStream.ConfigureAwait(false); +#elif !NETSTANDARD2_0 + var personStream = persons.Open(); + await using var disposablePersonStream = personStream.ConfigureAwait(false); +#else + using var personStream = persons.Open(); +#endif + +#if NETSTANDARD2_0 + var personDoc = XDocument.Load(personStream, LoadOptions.None); +#else + var personDoc = await XDocument.LoadAsync(personStream, LoadOptions.None, cancellationToken).ConfigureAwait(false); +#endif + var personElements = personDoc.Root?.Elements(ns18Tc + "person"); + people = personElements + ?.Select(p => new Author + { + Id = Guid.Parse(p.Attribute("id")!.Value), + DisplayName = p.Attribute("displayName")?.Value is { } name and not "" ? name : "???", + ProviderId = p.Attribute("providerId")?.Value, + }) + .ToList() ?? []; + } + + if (Archive.GetEntry($"xl/worksheets/_rels/{sheetFile}.rels") is not { } rel) + return new CommentResultSet(sheetName, [], []); + +#if NET10_0_OR_GREATER + var stream = await rel.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableStream = stream.ConfigureAwait(false); +#elif !NETSTANDARD2_0 + var stream = rel.Open(); + await using var disposableStream = stream.ConfigureAwait(false); +#else + using var stream = rel.Open(); +#endif + +#if NETSTANDARD2_0 + var relDoc = XDocument.Load(stream, LoadOptions.None); +#else + var relDoc = await XDocument.LoadAsync(stream, LoadOptions.None, cancellationToken).ConfigureAwait(false); +#endif + + var threadedCommentRels = relDoc.Root?.Elements(nsRel + "Relationship"); + var threadedCommentsElement = threadedCommentRels?.FirstOrDefault(x => x.Attribute("Type")?.Value == Schemas.SpreadsheetmlXmlThreadedComment); + var threadedCommentsTarget = threadedCommentsElement?.Attribute("Target"); + var threadedCommentsPath = threadedCommentsTarget?.Value.TrimStart('.', '/'); + + var noteRels = relDoc.Root?.Elements(nsRel + "Relationship"); + var notesElement = noteRels?.FirstOrDefault(x => x.Attribute("Type")?.Value == Schemas.SpreadsheetmlXmlComments); + var notesTarget = notesElement?.Attribute("Target"); + var notesPath = notesTarget?.Value.TrimStart('.', '/'); + + List commentThreads = []; + List notes = []; + HashSet refCells = []; + if (Archive.GetEntry($"xl/{threadedCommentsPath}") is { } threadEntry) + { +#if NET10_0_OR_GREATER + var threadEntryStream = await threadEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableThreadEntryStream = threadEntryStream.ConfigureAwait(false); +#elif !NETSTANDARD2_0 + var threadEntryStream = threadEntry.Open(); + await using var disposableThreadEntryStream = threadEntryStream.ConfigureAwait(false); +#else + using var threadEntryStream = threadEntry.Open(); +#endif + +#if NETSTANDARD2_0 + var doc = XDocument.Load(threadEntryStream, LoadOptions.None); +#else + var doc = await XDocument.LoadAsync(threadEntryStream, LoadOptions.None, cancellationToken).ConfigureAwait(false); +#endif + + var commentThreadElements = doc.Root?.Elements(ns18Tc + "threadedComment"); + commentThreads = commentThreadElements + ?.Where(tc => tc.Attribute("parentId") is null) + .Select(tc => new ThreadedComment + { + Id = Guid.Parse(tc.Attribute("id")!.Value.Trim('{', '}')), + Author = people.FirstOrDefault(p => p.Id == (Guid.TryParse(tc.Attribute("personId")?.Value, out var person) ? person : Guid.Empty)), + CreatedAt = DateTime.Parse(tc.Attribute("dT")!.Value, CultureInfo.InvariantCulture), + ReferenceCell = tc.Attribute("ref")?.Value!, + Text = tc.Value, + Resolved = tc.Attribute("done")?.Value is not (null or "0") + }) + .ToList() ?? []; + + var replyElements = doc.Root?.Elements(ns18Tc + "threadedComment"); + var replies = replyElements + ?.Where(tc => tc.Attribute("parentId") is not null) + .Select(tc => new ThreadedCommentReply + { + Id = Guid.Parse(tc.Attribute("id")!.Value.Trim('{', '}')), + ParentId = Guid.Parse(tc.Attribute("parentId")!.Value), + Author = people.FirstOrDefault(p => p.Id == Guid.Parse(tc.Attribute("personId")!.Value)), + CreatedAt = DateTime.Parse(tc.Attribute("dT")!.Value, CultureInfo.InvariantCulture), + Text = tc.Value + }) + .ToLookup(x => x.ParentId); + + if (replies is not null) + { + foreach (var thread in commentThreads) + { + thread.ThreadedComments = replies[thread.Id].ToList(); + } + } + + refCells = [..commentThreads.Select(x => x.ReferenceCell)]; + } + + if (Archive.GetEntry($"xl/{notesPath}") is { } noteEntry) + { +#if NET10_0_OR_GREATER + var noteEntryStream = await noteEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableNoteEntryStream = noteEntryStream.ConfigureAwait(false); +#elif !NETSTANDARD2_0 + var noteEntryStream = noteEntry.Open(); + await using var disposableNoteEntryStream = noteEntryStream.ConfigureAwait(false); +#else + using var noteEntryStream = noteEntry.Open(); +#endif + +#if NETSTANDARD2_0 + var doc = XDocument.Load(noteEntryStream, LoadOptions.None); +#else + var doc = await XDocument.LoadAsync(noteEntryStream, LoadOptions.None, cancellationToken).ConfigureAwait(false); +#endif + + var authorElements = doc.Root?.Element(nsMain + "authors")?.Elements(nsMain + "author"); + var authors = authorElements?.Select(a => a.Value).ToArray(); + + var commentElements = doc.Root + ?.Element(nsMain + "commentList") + ?.Elements(nsMain + "comment"); + + notes = commentElements + ?.Where(c => !refCells.Contains(c.Attribute("ref")?.Value)) + .Select(c => new NoteComment + { + Id = Guid.TryParse(c.Attribute(ns14R + "uid")?.Value.Trim('{', '}'), out var noteId) ? noteId : Guid.Empty, + Author = int.TryParse(c.Attribute("authorId")?.Value, out var authorId) ? authors?.ElementAtOrDefault(authorId) : "", + ReferenceCell = c.Attribute("ref")?.Value, + Text = string.Join("", GetTextFromComment(c)) + }) + .ToList() ?? []; + } + + return new CommentResultSet(sheetName, commentThreads, notes); + + IEnumerable GetTextFromComment(XElement? comment) + { + return comment?.Element(nsMain + "text") is { } textElement + ? textElement.Descendants(nsMain + "t").Select(t => t.Value) + : []; + } } /// @@ -1140,7 +1352,11 @@ internal async IAsyncEnumerable QueryMappedAsync( }; #if NET10_0_OR_GREATER - using var sheetStream = await sheetEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + var sheetStream = await sheetEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableSheetStream = sheetStream.ConfigureAwait(false); +#elif !NETSTANDARD2_0 + var sheetStream = sheetEntry.Open(); + await using var disposableSheetStream = sheetStream.ConfigureAwait(false); #else using var sheetStream = sheetEntry.Open(); #endif @@ -1271,4 +1487,9 @@ protected void Dispose(bool disposing) _disposed = true; } } + + ~OpenXmlReader() + { + Dispose(false); + } } diff --git a/src/MiniExcel.OpenXml/Picture/OpenXmlPictureImplement.cs b/src/MiniExcel.OpenXml/Picture/OpenXmlPictureImplement.cs index 7f3c227e..817a7d86 100644 --- a/src/MiniExcel.OpenXml/Picture/OpenXmlPictureImplement.cs +++ b/src/MiniExcel.OpenXml/Picture/OpenXmlPictureImplement.cs @@ -35,7 +35,7 @@ public static async Task AddPictureAsync(Stream excelStream, CancellationToken c { var sheetName = sheetGroup.Key; var sheetEnt = sheetEntries.Find(x => x.Name == sheetName) ?? sheetEntries[0]; - var sheetXmlName = sheetEnt.Path.Split('/').Last().Split('.')[0]; + var sheetXmlName = !string.IsNullOrEmpty(sheetEnt.Path) ? sheetEnt.Path.Split('/')[^1].Split('.')[0] : null; var sheetPath = $"xl/worksheets/{sheetXmlName}.xml"; var sheetEntry = archive.GetEntry(sheetPath); diff --git a/src/MiniExcel.OpenXml/Styles/OpenXmlStyles.cs b/src/MiniExcel.OpenXml/Styles/OpenXmlStyles.cs index 637a20b3..fa11822e 100644 --- a/src/MiniExcel.OpenXml/Styles/OpenXmlStyles.cs +++ b/src/MiniExcel.OpenXml/Styles/OpenXmlStyles.cs @@ -5,7 +5,7 @@ namespace MiniExcelLib.OpenXml.Styles; internal class OpenXmlStyles { - private static readonly string[] Ns = [Schemas.SpreadsheetmlXmlns, Schemas.SpreadsheetmlXmlStrictns]; + private static readonly string[] Ns = [Schemas.SpreadsheetmlXmlNs, Schemas.SpreadsheetmlXmlStrictNs]; private readonly Dictionary _cellXfs = new(); private readonly Dictionary _cellStyleXfs = new(); diff --git a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs index 91b69bc0..2c77b1cc 100644 --- a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs +++ b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs @@ -967,7 +967,7 @@ private void ProcessFormulas(StringBuilder rowXml, int rowIndex) if (!v.InnerText.StartsWith("$=")) continue; - var fNode = c.OwnerDocument.CreateElement("f", Schemas.SpreadsheetmlXmlns); + var fNode = c.OwnerDocument.CreateElement("f", Schemas.SpreadsheetmlXmlNs); fNode.InnerText = v.InnerText[2..]; c.InsertBefore(fNode, v); c.RemoveChild(v); @@ -1022,11 +1022,11 @@ private static void ReplaceSharedStringsToStr(IDictionary sharedStr var prefix = v.Prefix; c.RemoveChild(v); var isNode = string.IsNullOrEmpty(prefix) - ? c.OwnerDocument.CreateElement("is", Schemas.SpreadsheetmlXmlns) - : c.OwnerDocument.CreateElement(prefix, "is", Schemas.SpreadsheetmlXmlns); + ? c.OwnerDocument.CreateElement("is", Schemas.SpreadsheetmlXmlNs) + : c.OwnerDocument.CreateElement(prefix, "is", Schemas.SpreadsheetmlXmlNs); var tNode = string.IsNullOrEmpty(prefix) - ? c.OwnerDocument.CreateElement("t", Schemas.SpreadsheetmlXmlns) - : c.OwnerDocument.CreateElement(prefix, "t", Schemas.SpreadsheetmlXmlns); + ? c.OwnerDocument.CreateElement("t", Schemas.SpreadsheetmlXmlNs) + : c.OwnerDocument.CreateElement(prefix, "t", Schemas.SpreadsheetmlXmlNs); tNode.InnerText = shared; isNode.AppendChild(tNode); c.AppendChild(isNode); @@ -1054,11 +1054,11 @@ private static void SetCellType(XmlElement c, string type) var text = v.InnerText; c.RemoveChild(v); var isNode = string.IsNullOrEmpty(prefix) - ? c.OwnerDocument.CreateElement("is", Schemas.SpreadsheetmlXmlns) - : c.OwnerDocument.CreateElement(prefix, "is", Schemas.SpreadsheetmlXmlns); + ? c.OwnerDocument.CreateElement("is", Schemas.SpreadsheetmlXmlNs) + : c.OwnerDocument.CreateElement(prefix, "is", Schemas.SpreadsheetmlXmlNs); var tNode = string.IsNullOrEmpty(prefix) - ? c.OwnerDocument.CreateElement("t", Schemas.SpreadsheetmlXmlns) - : c.OwnerDocument.CreateElement(prefix, "t", Schemas.SpreadsheetmlXmlns); + ? c.OwnerDocument.CreateElement("t", Schemas.SpreadsheetmlXmlNs) + : c.OwnerDocument.CreateElement(prefix, "t", Schemas.SpreadsheetmlXmlNs); tNode.InnerText = text; isNode.AppendChild(tNode); c.AppendChild(isNode); @@ -1067,11 +1067,11 @@ private static void SetCellType(XmlElement c, string type) { // Create empty if neither nor exists var isNode = string.IsNullOrEmpty(prefix) - ? c.OwnerDocument.CreateElement("is", Schemas.SpreadsheetmlXmlns) - : c.OwnerDocument.CreateElement(prefix, "is", Schemas.SpreadsheetmlXmlns); + ? c.OwnerDocument.CreateElement("is", Schemas.SpreadsheetmlXmlNs) + : c.OwnerDocument.CreateElement(prefix, "is", Schemas.SpreadsheetmlXmlNs); var tNode = string.IsNullOrEmpty(prefix) - ? c.OwnerDocument.CreateElement("t", Schemas.SpreadsheetmlXmlns) - : c.OwnerDocument.CreateElement(prefix, "t", Schemas.SpreadsheetmlXmlns); + ? c.OwnerDocument.CreateElement("t", Schemas.SpreadsheetmlXmlNs) + : c.OwnerDocument.CreateElement(prefix, "t", Schemas.SpreadsheetmlXmlNs); isNode.AppendChild(tNode); c.AppendChild(isNode); } @@ -1093,8 +1093,8 @@ private static void SetCellType(XmlElement c, string type) var text = tNode?.InnerText ?? string.Empty; c.RemoveChild(isNode); var v = string.IsNullOrEmpty(prefix) - ? c.OwnerDocument.CreateElement("v", Schemas.SpreadsheetmlXmlns) - : c.OwnerDocument.CreateElement(prefix, "v", Schemas.SpreadsheetmlXmlns); + ? c.OwnerDocument.CreateElement("v", Schemas.SpreadsheetmlXmlNs) + : c.OwnerDocument.CreateElement(prefix, "v", Schemas.SpreadsheetmlXmlNs); v.InnerText = text; c.AppendChild(v); } diff --git a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs index b264e58b..2edceeae 100644 --- a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs +++ b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs @@ -22,7 +22,7 @@ internal partial class OpenXmlTemplate : IMiniExcelTemplate static OpenXmlTemplate() { Ns = new XmlNamespaceManager(new NameTable()); - Ns.AddNamespace("x", Schemas.SpreadsheetmlXmlns); + Ns.AddNamespace("x", Schemas.SpreadsheetmlXmlNs); Ns.AddNamespace("x14ac", Schemas.SpreadsheetmlXmlX14Ac); } diff --git a/src/MiniExcel.OpenXml/Utils/XmlReaderHelper.cs b/src/MiniExcel.OpenXml/Utils/XmlReaderHelper.cs index da4a4e00..c0d9c826 100644 --- a/src/MiniExcel.OpenXml/Utils/XmlReaderHelper.cs +++ b/src/MiniExcel.OpenXml/Utils/XmlReaderHelper.cs @@ -4,7 +4,7 @@ namespace MiniExcelLib.OpenXml.Utils; internal static partial class XmlReaderHelper { - private static readonly string[] Ns = [Schemas.SpreadsheetmlXmlns, Schemas.SpreadsheetmlXmlStrictns]; + private static readonly string[] Ns = [Schemas.SpreadsheetmlXmlNs, Schemas.SpreadsheetmlXmlStrictNs]; /// /// Pass <?xml> and <worksheet> diff --git a/tests/MiniExcel.OpenXml.Tests/Comments/CommentsRetrievalAsyncTests.cs b/tests/MiniExcel.OpenXml.Tests/Comments/CommentsRetrievalAsyncTests.cs new file mode 100644 index 00000000..bcbacf9e --- /dev/null +++ b/tests/MiniExcel.OpenXml.Tests/Comments/CommentsRetrievalAsyncTests.cs @@ -0,0 +1,126 @@ +using MiniExcelLib.Tests.Common.Utils; + +namespace MiniExcelLib.OpenXml.Tests.Comments; + +public class CommentsRetrievalAsyncTests +{ + private readonly OpenXmlImporter _excelImporter = MiniExcel.Importers.GetOpenXmlImporter(); + + [Fact] + public async Task SheetWithCommentsAndNotesTestAsync() + { + var commentSet = await _excelImporter.RetrieveCommentsAsync(PathHelper.GetFile("xlsx/TestCommentsAndNotes.xlsx"), "sheet1"); + var (firstComment, secondComment) = (commentSet.Comments[0], commentSet.Comments[1]); + + Assert.Equal("sheet1", commentSet.SheetName, ignoreCase: true); + Assert.Equal(2, commentSet.Comments.Count); + + Assert.Equal("B3", firstComment.ReferenceCell); + Assert.Equal(new DateTime(2026, 3, 21, 12, 7, 24), firstComment.CreatedAt); + Assert.Equal(new Guid("8d44beaf-9259-4d6a-8559-58427a76727b"), firstComment.Id); + Assert.Equal("this is a comment", firstComment.Text); + Assert.Equal(new Guid("cb8b42e9-e059-4d6b-b054-b1437d6cf7cd"), firstComment.Author?.Id); + Assert.Equal("John Doe", firstComment.Author?.DisplayName); + Assert.Equal("google-sheets", firstComment.Author?.ProviderId); + Assert.Equal(2, firstComment.Replies.Count); + Assert.False(firstComment.Resolved); + + Assert.Equal(new Guid("dfb1d4cd-7f1f-42ae-9f61-330f03f0b9ad"), firstComment.Replies[0].Id); + Assert.Equal(new Guid("8d44beaf-9259-4d6a-8559-58427a76727b"), firstComment.Replies[0].ParentId); + Assert.Equal(new DateTime(2026, 3, 21, 21, 17, 45), firstComment.Replies[0].CreatedAt); + Assert.Equal("Mary Sue", firstComment.Replies[0].Author?.DisplayName); + Assert.Equal("this is a reply", firstComment.Replies[0].Text); + + Assert.Equal(new Guid("d99bde2c-3df5-4300-a12e-2cc3b831c5dd"), firstComment.Replies[1].Id); + Assert.Equal(new Guid("8d44beaf-9259-4d6a-8559-58427a76727b"), firstComment.Replies[1].ParentId); + Assert.Equal(new DateTime(2026, 3, 21, 21, 20, 3), firstComment.Replies[1].CreatedAt); + Assert.Equal("John Doe", firstComment.Replies[1].Author?.DisplayName); + Assert.Equal("this is another reply", firstComment.Replies[1].Text); + + Assert.Empty(secondComment.Replies); + Assert.Equal("E2", secondComment.ReferenceCell); + Assert.Equal(new Guid("0fdf4b1e-0d47-4717-9dd5-c9fc731b0ad6"), secondComment.Id); + Assert.Equal(new DateTime(2026, 3, 21, 21, 35, 17), secondComment.CreatedAt); + Assert.Equal(new Guid("eaf7fda0-61e5-4210-9faa-da7028ea718a"), secondComment.Author?.Id); + Assert.Equal("Mary Sue", secondComment.Author?.DisplayName); + Assert.Equal("AD", secondComment.Author?.ProviderId); + Assert.False(secondComment.Resolved); + Assert.Equal("this is a separate comment", secondComment.Text); + + Assert.Equal(2, commentSet.Notes.Count); + var (firstNote, secondNote) = (commentSet.Notes[0], commentSet.Notes[1]); + + Assert.Equal(new Guid("00000000-0006-0000-0000-000001000000"), firstNote.Id); + Assert.Equal("D6", firstNote.ReferenceCell); + Assert.Empty(firstNote.Author ?? ""); + Assert.Equal("this is a simple note", firstNote.Text); + + Assert.Equal(new Guid("4e01653b-66e0-48be-9390-2bddb28a7255"), secondNote.Id); + Assert.Equal("G10", secondNote.ReferenceCell); + Assert.Equal("local user", secondNote.Author); + Assert.Equal("local user:\nthis is a note from someone else", secondNote.Text); + } + + [Fact] + public async Task SheetWithNotesAndCommentsWithoutRepliesTestAsync() + { + var commentSet = await _excelImporter.RetrieveCommentsAsync(PathHelper.GetFile("xlsx/TestCommentsAndNotes.xlsx"), "sheet2"); + var comment = commentSet.Comments[0]; + + Assert.Equal("sheet2", commentSet.SheetName, ignoreCase: true); + Assert.Single(commentSet.Comments); + Assert.Empty(comment.Replies); + Assert.Equal("A3", comment.ReferenceCell); + Assert.Equal(new Guid("597d85de-079d-4129-8ebb-e6a9666c1c31"), comment.Id); + Assert.Equal(new DateTime(2026, 3, 21, 12, 8, 22), comment.CreatedAt); + Assert.Equal(new Guid("cb8b42e9-e059-4d6b-b054-b1437d6cf7cd"), comment.Author?.Id); + Assert.Equal("John Doe", comment.Author?.DisplayName); + Assert.Equal("google-sheets", comment.Author?.ProviderId); + Assert.False(comment.Resolved); + Assert.Equal("this is a comment on another sheet", comment.Text); + + Assert.Single(commentSet.Notes); + var note = commentSet.Notes[0]; + + Assert.Equal(new Guid("00000000-0006-0000-0100-000001000000"), note.Id); + Assert.Equal("B11", note.ReferenceCell); + Assert.Empty(commentSet.Notes[0].Author ?? ""); + Assert.Equal("this is a note on another sheet", note.Text); + } + + [Fact] + public async Task SheetWithoutNotesNorCommentsTestAsync() + { + var commentSet = await _excelImporter.RetrieveCommentsAsync(PathHelper.GetFile("xlsx/TestCommentsAndNotes.xlsx"), "sheet3"); + Assert.Equal("sheet3", commentSet.SheetName, ignoreCase: true); + Assert.Empty(commentSet.Comments); + Assert.Empty(commentSet.Notes); + } + + [Fact] + public async Task SheetWithResolvedThreadedCommentsTestAsync() + { + var commentSet = await _excelImporter.RetrieveCommentsAsync(PathHelper.GetFile("xlsx/TestCommentsAndNotes.xlsx"), "sheet4"); + var comment = commentSet.Comments[0]; + + Assert.Single(commentSet.Comments); + Assert.Equal("sheet4", commentSet.SheetName, ignoreCase: true); + Assert.Equal("D2", comment.ReferenceCell); + Assert.Equal(new DateTime(2026, 3, 21, 12, 34, 24), comment.CreatedAt); + Assert.Equal(new Guid("cc210736-0fae-4525-aa57-df776a5548fa"), comment.Id); + Assert.Equal("this thread will be resolved", comment.Text); + Assert.Equal(new Guid("cb8b42e9-e059-4d6b-b054-b1437d6cf7cd"), comment.Author?.Id); + Assert.Equal("John Doe", comment.Author?.DisplayName); + Assert.Equal("google-sheets", comment.Author?.ProviderId); + Assert.Single(comment.Replies); + Assert.True(comment.Resolved); + + Assert.Equal(new Guid("f4863ec3-3a84-453a-88a7-ed634a96dd18"), comment.Replies[0].Id); + Assert.Equal(new Guid("cc210736-0fae-4525-aa57-df776a5548fa"), comment.Replies[0].ParentId); + Assert.Equal(new DateTime(2026, 3, 21, 21, 20, 55), comment.Replies[0].CreatedAt); + Assert.Equal(new Guid("eaf7fda0-61e5-4210-9faa-da7028ea718a"), comment.Replies[0].Author?.Id); + Assert.Equal("Mary Sue", comment.Replies[0].Author?.DisplayName); + Assert.Equal("AD", comment.Replies[0].Author?.ProviderId); + Assert.Equal("ok", comment.Replies[0].Text); + } +} diff --git a/tests/MiniExcel.OpenXml.Tests/Comments/CommentsRetrievalTests.cs b/tests/MiniExcel.OpenXml.Tests/Comments/CommentsRetrievalTests.cs new file mode 100644 index 00000000..f4162278 --- /dev/null +++ b/tests/MiniExcel.OpenXml.Tests/Comments/CommentsRetrievalTests.cs @@ -0,0 +1,125 @@ +using MiniExcelLib.Tests.Common.Utils; + +namespace MiniExcelLib.OpenXml.Tests.Comments; + +public class CommentsRetrievalTests +{ + private readonly OpenXmlImporter _excelImporter = MiniExcel.Importers.GetOpenXmlImporter(); + + [Fact] + public void SheetWithNotesAndCommentsWithRepliesTest() + { + var commentSet = _excelImporter.RetrieveComments(PathHelper.GetFile("xlsx/TestCommentsAndNotes.xlsx"), "sheet1"); + var (firstComment, secondComment) = (commentSet.Comments[0], commentSet.Comments[1]); + + Assert.Equal("sheet1", commentSet.SheetName, ignoreCase: true); + Assert.Equal(2, commentSet.Comments.Count); + + Assert.Equal("B3", firstComment.ReferenceCell); + Assert.Equal(new DateTime(2026, 3, 21, 12, 7, 24), firstComment.CreatedAt); + Assert.Equal(new Guid("8d44beaf-9259-4d6a-8559-58427a76727b"), firstComment.Id); + Assert.Equal("this is a comment", firstComment.Text); + Assert.Equal(new Guid("cb8b42e9-e059-4d6b-b054-b1437d6cf7cd"), firstComment.Author?.Id); + Assert.Equal("John Doe", firstComment.Author?.DisplayName); + Assert.Equal("google-sheets", firstComment.Author?.ProviderId); + Assert.Equal(2, firstComment.Replies.Count); + Assert.False(firstComment.Resolved); + + Assert.Equal(new Guid("dfb1d4cd-7f1f-42ae-9f61-330f03f0b9ad"), firstComment.Replies[0].Id); + Assert.Equal(new Guid("8d44beaf-9259-4d6a-8559-58427a76727b"), firstComment.Replies[0].ParentId); + Assert.Equal(new DateTime(2026, 3, 21, 21, 17, 45), firstComment.Replies[0].CreatedAt); + Assert.Equal("Mary Sue", firstComment.Replies[0].Author?.DisplayName); + Assert.Equal("this is a reply", firstComment.Replies[0].Text); + + Assert.Equal(new Guid("d99bde2c-3df5-4300-a12e-2cc3b831c5dd"), firstComment.Replies[1].Id); + Assert.Equal(new Guid("8d44beaf-9259-4d6a-8559-58427a76727b"), firstComment.Replies[1].ParentId); + Assert.Equal(new DateTime(2026, 3, 21, 21, 20, 3), firstComment.Replies[1].CreatedAt); + Assert.Equal("John Doe", firstComment.Replies[1].Author?.DisplayName); + Assert.Equal("this is another reply", firstComment.Replies[1].Text); + + Assert.Empty(secondComment.Replies); + Assert.Equal("E2", secondComment.ReferenceCell); + Assert.Equal(new Guid("0fdf4b1e-0d47-4717-9dd5-c9fc731b0ad6"), secondComment.Id); + Assert.Equal(new DateTime(2026, 3, 21, 21, 35, 17), secondComment.CreatedAt); + Assert.Equal(new Guid("eaf7fda0-61e5-4210-9faa-da7028ea718a"), secondComment.Author?.Id); + Assert.Equal("Mary Sue", secondComment.Author?.DisplayName); + Assert.Equal("AD", secondComment.Author?.ProviderId); + Assert.False(secondComment.Resolved); + Assert.Equal("this is a separate comment", secondComment.Text); + + Assert.Equal(2, commentSet.Notes.Count); + + Assert.Equal(new Guid("00000000-0006-0000-0000-000001000000"), commentSet.Notes[0].Id); + Assert.Equal("D6", commentSet.Notes[0].ReferenceCell); + Assert.Empty(commentSet.Notes[0].Author ?? ""); + Assert.Equal("this is a simple note", commentSet.Notes[0].Text); + + Assert.Equal(new Guid("4e01653b-66e0-48be-9390-2bddb28a7255"), commentSet.Notes[1].Id); + Assert.Equal("G10", commentSet.Notes[1].ReferenceCell); + Assert.Equal("local user", commentSet.Notes[1].Author); + Assert.Equal("local user:\nthis is a note from someone else", commentSet.Notes[1].Text); + } + + [Fact] + public void SheetWithNotesAndCommentsWithoutRepliesTest() + { + var commentSet = _excelImporter.RetrieveComments(PathHelper.GetFile("xlsx/TestCommentsAndNotes.xlsx"), "sheet2"); + var comment = commentSet.Comments[0]; + + Assert.Equal("sheet2", commentSet.SheetName, ignoreCase: true); + Assert.Single(commentSet.Comments); + Assert.Empty(comment.Replies); + Assert.Equal("A3", comment.ReferenceCell); + Assert.Equal(new Guid("597d85de-079d-4129-8ebb-e6a9666c1c31"), comment.Id); + Assert.Equal(new DateTime(2026, 3, 21, 12, 8, 22), comment.CreatedAt); + Assert.Equal(new Guid("cb8b42e9-e059-4d6b-b054-b1437d6cf7cd"), comment.Author?.Id); + Assert.Equal("John Doe", comment.Author?.DisplayName); + Assert.Equal("google-sheets", comment.Author?.ProviderId); + Assert.False(comment.Resolved); + Assert.Equal("this is a comment on another sheet", comment.Text); + + Assert.Single(commentSet.Notes); + var note = commentSet.Notes[0]; + + Assert.Equal(new Guid("00000000-0006-0000-0100-000001000000"), note.Id); + Assert.Equal("B11", note.ReferenceCell); + Assert.Empty(commentSet.Notes[0].Author ?? ""); + Assert.Equal("this is a note on another sheet", note.Text); + } + + [Fact] + public void SheetWithoutNotesNorCommentsTest() + { + var commentSet = _excelImporter.RetrieveComments(PathHelper.GetFile("xlsx/TestCommentsAndNotes.xlsx"), "sheet3"); + Assert.Equal("sheet3", commentSet.SheetName, ignoreCase: true); + Assert.Empty(commentSet.Comments); + Assert.Empty(commentSet.Notes); + } + + [Fact] + public void SheetWithResolvedThreadedCommentsTest() + { + var commentSet = _excelImporter.RetrieveComments(PathHelper.GetFile("xlsx/TestCommentsAndNotes.xlsx"), "sheet4"); + var comment = commentSet.Comments[0]; + + Assert.Single(commentSet.Comments); + Assert.Equal("sheet4", commentSet.SheetName, ignoreCase: true); + Assert.Equal("D2", comment.ReferenceCell); + Assert.Equal(new DateTime(2026, 3, 21, 12, 34, 24), comment.CreatedAt); + Assert.Equal(new Guid("cc210736-0fae-4525-aa57-df776a5548fa"), comment.Id); + Assert.Equal("this thread will be resolved", comment.Text); + Assert.Equal(new Guid("cb8b42e9-e059-4d6b-b054-b1437d6cf7cd"), comment.Author?.Id); + Assert.Equal("John Doe", comment.Author?.DisplayName); + Assert.Equal("google-sheets", comment.Author?.ProviderId); + Assert.Single(comment.Replies); + Assert.True(comment.Resolved); + + Assert.Equal(new Guid("f4863ec3-3a84-453a-88a7-ed634a96dd18"), comment.Replies[0].Id); + Assert.Equal(new Guid("cc210736-0fae-4525-aa57-df776a5548fa"), comment.Replies[0].ParentId); + Assert.Equal(new DateTime(2026, 3, 21, 21, 20, 55), comment.Replies[0].CreatedAt); + Assert.Equal(new Guid("eaf7fda0-61e5-4210-9faa-da7028ea718a"), comment.Replies[0].Author?.Id); + Assert.Equal("Mary Sue", comment.Replies[0].Author?.DisplayName); + Assert.Equal("AD", comment.Replies[0].Author?.ProviderId); + Assert.Equal("ok", comment.Replies[0].Text); + } +} diff --git a/tests/data/xlsx/TestCommentsAndNotes.xlsx b/tests/data/xlsx/TestCommentsAndNotes.xlsx new file mode 100644 index 00000000..3846cad4 Binary files /dev/null and b/tests/data/xlsx/TestCommentsAndNotes.xlsx differ