diff --git a/Jellyfin.Plugin.Reports/Api/Data/ExcelExport.cs b/Jellyfin.Plugin.Reports/Api/Data/ExcelExport.cs deleted file mode 100644 index c3398b9..0000000 --- a/Jellyfin.Plugin.Reports/Api/Data/ExcelExport.cs +++ /dev/null @@ -1,244 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.IO.Compression; -using System.Linq; -using System.Security; -using System.Text; -using Jellyfin.Plugin.Reports.Api.Model; - -namespace Jellyfin.Plugin.Reports.Api.Data -{ - /// Build XML files and generate XLSX archives from them. - public static class ExcelExport - { - /// Creates an XLSX file from - /// The results of a client query - /// A MemoryStream containing a XLSX file. - public static MemoryStream GenerateXlsx(ReportResult reportResult) - { - // Constant XML files to place into the XLSX archive - const string rootRels = ""; - const string rootContentTypes = ""; - const string workbookRels = ""; - const string workbookXml = ""; - const string styleXml = ""; - - static void AddStringToArchive(ZipArchive archive, string fileName, string content) - { - ZipArchiveEntry file = archive.CreateEntry(fileName, CompressionLevel.Optimal); - using (Stream entryStream = file.Open()) - using (StreamWriter streamWriter = new StreamWriter(entryStream)) - streamWriter.Write(content); - } - static void AddXmlToArchive(ZipArchive archive, string fileName, ExcelXmlBuilder content) - { - ZipArchiveEntry file = archive.CreateEntry(fileName, CompressionLevel.Optimal); - using (Stream entryStream = file.Open()) - using (StreamWriter streamWriter = new StreamWriter(entryStream)) - content.WriteXml(streamWriter); - } - - ExcelSharedString sharedString = new ExcelSharedString(); - ExcelSheet sheetObj = new ExcelSheet(reportResult, sharedString); - - // Add reportResult to ExcelSheet object - if (reportResult.IsGrouped) - { - reportResult.Groups.ForEach(group => - { - sheetObj.addGroupHeader(group.Name); - sheetObj.AddRows(group.Rows); - }); - } - else - { - sheetObj.AddRows(reportResult.Rows); - } - - // Write XLSX file - MemoryStream memoryStream = new MemoryStream(); - using (ZipArchive archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true)) - { - AddStringToArchive(archive, "_rels/.rels", rootRels); - AddStringToArchive(archive, "[Content_Types].xml", rootContentTypes); - AddStringToArchive(archive, "xl/_rels/workbook.xml.rels", workbookRels); - AddStringToArchive(archive, "xl/workbook.xml", workbookXml); - AddStringToArchive(archive, "xl/styles.xml", styleXml); - AddXmlToArchive(archive, "xl/sharedStrings.xml", sharedString); - AddXmlToArchive(archive, "xl/worksheets/sheet1.xml", sheetObj); - } - memoryStream.Position = 0; - return memoryStream; - } - - - /// Abstract superclass to enforce WriteXml method - abstract private class ExcelXmlBuilder - { - /// Write ExcelXmlBuilder object contents to a StreamWriter in a XML format - /// The StreamWriter to write the XML content to - abstract public void WriteXml(StreamWriter writer); - } - - /// XML builder for SharedStrings.xml file - private class ExcelSharedString : ExcelXmlBuilder - { - private int wordCount; - private readonly List wordList = new(); - private const string sharedStringXmlHeader = " Add a string to the workbook's shared string list (if not already in list), returning its index - /// The string to be added to the shared string list - /// Index of string in shared string list - public int AddString(string text) - { - int strPos = wordList.IndexOf(text); - wordCount++; - if (strPos == -1) - { - wordList.Add(text); - return wordList.Count - 1; - } - return strPos; - } - - public override void WriteXml(StreamWriter writer) - { - writer.Write(sharedStringXmlHeader); - writer.Write($"{wordCount}\" uniqueCount=\"{wordList.Count}\">"); - writer.Write(string.Join(null, wordList.Select(word => $"{SecurityElement.Escape(word)}"))); - writer.Write(""); - } - } - - /// XML builder for sheet XML files (e.g. sheet1.xml) - private class ExcelSheet : ExcelXmlBuilder - { - private readonly int numCols; - private int rowCount; - private readonly bool isGrouped; - private readonly int[] colWidths; - private readonly List groupHeaderRows = new(); - private readonly ExcelSharedString sharedString; - private readonly StringBuilder sheetXml; - private const int minColWidth = 10; - private const int maxColWidth = 50; - private const string sheetXmlHeader = ""; - private enum RowType - { - sheetHeader, - groupHeader, - standard - } - - public ExcelSheet(ReportResult reportResult, ExcelSharedString sharedString) - { - this.sharedString = sharedString; - isGrouped = reportResult.IsGrouped; - numCols = reportResult.Headers.Count; - colWidths = Enumerable.Repeat(minColWidth, numCols).ToArray(); - sheetXml = new StringBuilder(); - AddRow(reportResult.Headers.Select(s => s.Name).ToArray(), RowType.sheetHeader); - } - - /// Add rows to the Excel sheet - /// A list of ReportRows to be added - public void AddRows(List rows) - { - rows.ForEach(row => - { - AddRow(row.Columns.Select(s => s.Name).ToArray(), RowType.standard); - }); - } - - /// Add a group header row to the Excel sheet - /// The title to use for the header row - public void addGroupHeader(string header) - { - string[] groupHeaderRow = new string[numCols]; - groupHeaderRow[0] = header; - AddRow(groupHeaderRow, RowType.groupHeader); - groupHeaderRows.Add(rowCount); - } - - public override void WriteXml(StreamWriter writer) - { - writer.Write(sheetXmlHeader); - writer.Write(""); - writer.Write(string.Join(null, colWidths.Select((width,idx) => $""))); - writer.Write($""); - writer.Write(sheetXml); - writer.Write(""); - if (isGrouped) - { - string lastCol = ColIdxToColRef(numCols - 1); - writer.Write($""); - writer.Write(string.Join(null, groupHeaderRows.Select(groupRow => $""))); - writer.Write(""); - } - writer.Write(""); - } - - /// Add a row to the Excel sheet - /// String array of values to fill row with. One per cell, starting with column A - /// Type of row to add. Whether it's a title, group header, or standard row - private void AddRow(string[] rowVals, RowType rowType) - { - int rowStyle = rowType.Equals(RowType.standard) ? 2 : 1; - sheetXml.Append(CultureInfo.InvariantCulture, $""); - } - - /// - /// Converts a column index to a Excel Column Ref. - /// For example ColIdxToColRef(12)returns "M" - /// and ColIdxToColRef(30) returns "AE". - /// - /// The column index to be converted, should use zero-based indexing - /// Excel Column referecnce in terms of base-26 alphabetic string - private static string ColIdxToColRef(int colIdx) - { - LinkedList colRef = new(); - colIdx++; - while (colIdx > 0) - { - int rem = (colIdx - 1) % 26; - colRef.AddFirst(rem); - colIdx = (colIdx - rem) / 26; - } - return new string(colRef.Select(rem => Convert.ToChar('A' + rem)).ToArray()); - } - } - - - } -} diff --git a/Jellyfin.Plugin.Reports/Api/Data/ReportExport.cs b/Jellyfin.Plugin.Reports/Api/Data/ReportExport.cs index 628a9c4..093168c 100644 --- a/Jellyfin.Plugin.Reports/Api/Data/ReportExport.cs +++ b/Jellyfin.Plugin.Reports/Api/Data/ReportExport.cs @@ -4,7 +4,9 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; +using System.Reflection; using Jellyfin.Plugin.Reports.Api.Model; +using ClosedXML.Excel; namespace Jellyfin.Plugin.Reports.Api.Data { @@ -137,7 +139,68 @@ namespace Jellyfin.Plugin.Reports.Api.Data /// A MemoryStream containing a XLSX file. public static MemoryStream ExportToExcel(ReportResult reportResult) { - return ExcelExport.GenerateXlsx(reportResult); + static void AddHeaderStyle(IXLRange range) + { + range.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; + range.Style.Font.Bold = true; + range.Style.Fill.BackgroundColor = XLColor.FromArgb(222, 222, 222); + } + + static void AddReportRows(IXLWorksheet worksheet, List reportRows, ref int nextRow) + { + IEnumerable rows = reportRows.Select(r => r.Columns.Select(s => s.Name).ToArray()); + worksheet.Cell(nextRow, 1).InsertData(rows); + nextRow += rows.Count(); + } + + IXLWorkbook workbook = new XLWorkbook(XLEventTracking.Disabled); + IXLWorksheet worksheet = workbook.Worksheets.Add("ReportExport"); + + // Add report rows + int nextRow = 1; + IEnumerable headers = reportResult.Headers.Select(s => s.Name); + IXLRange headerRange = worksheet.Cell(nextRow++, 1).InsertData(headers, true); + AddHeaderStyle(headerRange); + if (reportResult.IsGrouped) + { + foreach (ReportGroup group in reportResult.Groups) + { + int groupHeaderRow = nextRow++; + worksheet.Cell(groupHeaderRow, 1).Value = group.Name; + AddHeaderStyle(worksheet.Cell(groupHeaderRow, 1).AsRange()); + worksheet.Range(groupHeaderRow, 1, groupHeaderRow, reportResult.Headers.Count).Merge(); + AddReportRows(worksheet, group.Rows, ref nextRow); + worksheet.Rows(groupHeaderRow + 1, nextRow - 1).Group(); + } + } + else + { + AddReportRows(worksheet, reportResult.Rows, ref nextRow); + } + + // Sheet properties + worksheet.Style.Font.FontColor = XLColor.FromArgb(51, 51, 51); + worksheet.Style.Font.FontName = "Arial"; + worksheet.Style.Font.FontSize = 9; + worksheet.ShowGridLines = false; + worksheet.SheetView.FreezeRows(1); + worksheet.Outline.SummaryVLocation = XLOutlineSummaryVLocation.Top; + worksheet.RangeUsed().Style.Border.InsideBorder = XLBorderStyleValues.Thin; + worksheet.RangeUsed().Style.Border.OutsideBorder = XLBorderStyleValues.Thin; + //worksheet.ColumnsUsed().AdjustToContents(10.0, 50.0); + + // Workbook properties + workbook.Properties.Author = "Jellyfin"; + workbook.Properties.Title = "ReportExport"; + string pluginVer = Assembly.GetExecutingAssembly().GetName().Version.ToString(); + workbook.Properties.Comments = $"Produced by Jellyfin Reports Plugin {pluginVer}"; + + // Save workbook to stream and return + MemoryStream memoryStream = new MemoryStream(); + workbook.SaveAs(memoryStream); + workbook.Dispose(); + memoryStream.Position = 0; + return memoryStream; } } } diff --git a/Jellyfin.Plugin.Reports/Jellyfin.Plugin.Reports.csproj b/Jellyfin.Plugin.Reports/Jellyfin.Plugin.Reports.csproj index 36bc4da..fa75f7e 100644 --- a/Jellyfin.Plugin.Reports/Jellyfin.Plugin.Reports.csproj +++ b/Jellyfin.Plugin.Reports/Jellyfin.Plugin.Reports.csproj @@ -20,6 +20,7 @@ + diff --git a/build.yaml b/build.yaml index 858cdb3..7bca32f 100644 --- a/build.yaml +++ b/build.yaml @@ -11,6 +11,15 @@ description: "Generate reports of your media library" category: "General" artifacts: - "Jellyfin.Plugin.Reports.dll" + - "ClosedXML.dll" + - "DocumentFormat.OpenXml.dll" + - "ExcelNumberFormat.dll" + - "Microsoft.Win32.SystemEvents.dll" + - "System.Drawing.Common.dll" + - "System.IO.Packaging.dll" + - "runtimes/unix/lib/netcoreapp2.0/System.Drawing.Common.dll" + - "runtimes/win/lib/netcoreapp2.0/Microsoft.Win32.SystemEvents.dll" + - "runtimes/win/lib/netcoreapp2.0/System.Drawing.Common.dll" changelog: |2- ### Bug Fixes ### - Fix CSV Export Format (#73) @mwildgoose