diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..e84ae55 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,51 @@ +name: TypeScript and .NET Tests + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + workflow_dispatch: + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Use Node.js 18 + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + cache-dependency-path: 'typescript/package-lock.json' + + - name: Install TypeScript dependencies + run: npm ci + working-directory: typescript + + - name: Build TypeScript package + run: npm run build + working-directory: typescript + + - name: Test TypeScript package + run: npm test + working-directory: typescript + + - name: Setup .NET 8 SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore .NET solution + run: dotnet restore ConnectedWorkbooks.sln + working-directory: dotnet + + - name: Build .NET solution + run: dotnet build ConnectedWorkbooks.sln --configuration Release --no-restore + working-directory: dotnet + + - name: Test .NET solution + run: dotnet test ConnectedWorkbooks.sln --configuration Release --no-build + working-directory: dotnet diff --git a/.gitignore b/.gitignore index 635044a..11ccbe1 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,10 @@ PublishScripts/ node_modules/ +# TypeScript artifacts +typescript/node_modules/ +typescript/dist/ + # Build files dist/ @@ -20,4 +24,16 @@ dist/ coverage/ # bundles -*.tgz \ No newline at end of file +*.tgz + +# .NET artifacts +dotnet/**/bin/ +dotnet/**/obj/ + +# Python artifacts +python/.venv/ +python/**/__pycache__/ + +# Test files +**/WeatherSample.xlsx +**/WeatherSample.ts.xlsx \ No newline at end of file diff --git a/README.md b/README.md index 6d08b16..371b0cd 100644 --- a/README.md +++ b/README.md @@ -63,14 +63,42 @@ Open In Excel powers data export functionality across Microsoft's enterprise pla npm install @microsoft/connected-workbooks ``` +#### .NET Consumers + +```bash +dotnet add package Microsoft.ConnectedWorkbooks --prerelease +``` + +```csharp +using System.Collections.Generic; +using Microsoft.ConnectedWorkbooks; +using Microsoft.ConnectedWorkbooks.Models; + +var manager = new WorkbookManager(); +var grid = new Grid(new List> +{ + new List { "Product", "Revenue" }, + new List { "Surface Laptop", 1299.99 }, + new List { "Office 365", 99.99 } +}); + +var workbook = manager.GenerateTableWorkbookFromGrid(grid); +await File.WriteAllBytesAsync("Workbook.xlsx", workbook); +``` + --- ## πŸ’‘ Usage Examples +Each scenario lists both TypeScript and .NET code. GitHub Markdown does not support interactive tabs, so we use collapsible `
` sectionsβ€”expand the platform you care about and keep scrolling. + ### πŸ“‹ **HTML Table Export** Perfect for quick data exports from existing web tables. +
+TypeScript + ```typescript import { workbookManager } from '@microsoft/connected-workbooks'; @@ -83,10 +111,37 @@ const blob = await workbookManager.generateTableWorkbookFromHtml( workbookManager.openInExcelWeb(blob, "QuickExport.xlsx", true); ``` +
+ +
+.NET + +```csharp +using System.Collections.Generic; +using Microsoft.ConnectedWorkbooks; +using Microsoft.ConnectedWorkbooks.Models; + +var manager = new WorkbookManager(); +var grid = new Grid(new List> +{ + new List { "Product", "Revenue", "InStock", "Category" }, + new List { "Surface Laptop", 1299.99, true, "Hardware" }, + new List { "Azure Credits", 500.00, false, "Cloud" } +}); + +var workbook = manager.GenerateTableWorkbookFromGrid(grid); +await File.WriteAllBytesAsync("QuickExport.xlsx", workbook); +``` + +
+ ### πŸ“Š **Smart Data Formatting** Transform raw data arrays into professionally formatted Excel tables. +
+TypeScript + ```typescript import { workbookManager } from '@microsoft/connected-workbooks'; @@ -108,6 +163,37 @@ const blob = await workbookManager.generateTableWorkbookFromGrid(salesData); workbookManager.openInExcelWeb(blob, "SalesReport.xlsx", true); ``` +
+ +
+.NET + +```csharp +using System.Collections.Generic; +using Microsoft.ConnectedWorkbooks; +using Microsoft.ConnectedWorkbooks.Models; + +var manager = new WorkbookManager(); +var grid = new Grid( + new List> + { + new List { "Product", "Revenue", "InStock", "Category" }, + new List { "Surface Laptop", 1299.99, true, "Hardware" }, + new List { "Office 365", 99.99, true, "Software" }, + new List { "Azure Credits", 500.00, false, "Cloud" } + }, + new GridConfig + { + PromoteHeaders = true, + AdjustColumnNames = true + }); + +var workbook = manager.GenerateTableWorkbookFromGrid(grid); +await File.WriteAllBytesAsync("SalesReport.xlsx", workbook); +``` + +
+
Smart Formatted Excel Table
@@ -149,6 +235,9 @@ The library will then populate the designated table with your data. Any function Download before.xlsx β€’ Download after.xlsx +
+TypeScript + #### πŸ“ **Loading Template Files** ```typescript @@ -198,6 +287,41 @@ const blob = await workbookManager.generateTableWorkbookFromGrid( workbookManager.openInExcelWeb(blob, "Q4_Executive_Dashboard.xlsx", true); ``` +
+ +
+.NET + +```csharp +using System.Collections.Generic; +using Microsoft.ConnectedWorkbooks; +using Microsoft.ConnectedWorkbooks.Models; + +var manager = new WorkbookManager(); +var grid = new Grid(new List> +{ + new List { "Region", "Q3_Revenue", "Q4_Revenue", "Growth", "Target_Met" }, + new List { "North America", 2_500_000, 2_750_000, "10%", true }, + new List { "Europe", 1_800_000, 2_100_000, "17%", true } +}); + +var templateBytes = await File.ReadAllBytesAsync("Templates/sales-dashboard.xlsx"); +var configuration = new FileConfiguration +{ + TemplateBytes = templateBytes, + TemplateSettings = new TemplateSettings + { + SheetName = "Dashboard", + TableName = "QuarterlyData" + } +}; + +var workbook = manager.GenerateTableWorkbookFromGrid(grid, configuration); +await File.WriteAllBytesAsync("Q4_Executive_Dashboard.xlsx", workbook); +``` + +
+
Custom Branded Excel Dashboard
@@ -208,6 +332,9 @@ workbookManager.openInExcelWeb(blob, "Q4_Executive_Dashboard.xlsx", true); Create workbooks that automatically refresh from your data sources. +
+TypeScript + ```typescript import { workbookManager } from '@microsoft/connected-workbooks'; @@ -223,6 +350,34 @@ const blob = await workbookManager.generateSingleQueryWorkbook({ workbookManager.openInExcelWeb(blob, "MyData.xlsx", true); ``` +
+ +
+.NET + +```csharp +using Microsoft.ConnectedWorkbooks; +using Microsoft.ConnectedWorkbooks.Models; + +var manager = new WorkbookManager(); +var query = new QueryInfo +{ + QueryMashup = """ +let + Source = {1..10} +in + Source +""", + RefreshOnOpen = true, + QueryName = "Query1" +}; + +var workbook = manager.GenerateSingleQueryWorkbook(query); +await File.WriteAllBytesAsync("MyData.xlsx", workbook); +``` + +
+ > πŸ“š **Learn Power Query**: New to Power Query? Check out the [official documentation](https://docs.microsoft.com/en-us/power-query/) to unlock the full potential of live data connections.
@@ -232,6 +387,9 @@ workbookManager.openInExcelWeb(blob, "MyData.xlsx", true); Add metadata and professional document properties for enterprise use. +
+TypeScript + ```typescript const blob = await workbookManager.generateTableWorkbookFromHtml( document.querySelector('table') as HTMLTableElement, @@ -249,6 +407,40 @@ const blob = await workbookManager.generateTableWorkbookFromHtml( workbookManager.downloadWorkbook(blob, "MyTable.xlsx"); ``` +
+ +
+.NET + +```csharp +using System.Collections.Generic; +using Microsoft.ConnectedWorkbooks; +using Microsoft.ConnectedWorkbooks.Models; + +var manager = new WorkbookManager(); +var grid = new Grid(new List> +{ + new List { "Product", "Revenue" }, + new List { "Surface Laptop", 1299.99 } +}); + +var configuration = new FileConfiguration +{ + DocumentProperties = new DocumentProperties + { + CreatedBy = "John Doe", + LastModifiedBy = "Jane Doe", + Description = "Sales Report Q4 2024", + Title = "Quarterly Sales Data" + } +}; + +var workbook = manager.GenerateTableWorkbookFromGrid(grid, configuration); +await File.WriteAllBytesAsync("MyTable.xlsx", workbook); +``` + +
+
Professional Document Properties
@@ -260,6 +452,9 @@ workbookManager.downloadWorkbook(blob, "MyTable.xlsx"); #### πŸ”— `generateSingleQueryWorkbook()` Create Power Query connected workbooks with live data refresh capabilities. +
+TypeScript + ```typescript async function generateSingleQueryWorkbook( query: QueryInfo, @@ -268,6 +463,24 @@ async function generateSingleQueryWorkbook( ): Promise ``` +
+ +
+.NET + +```csharp +using Microsoft.ConnectedWorkbooks; +using Microsoft.ConnectedWorkbooks.Models; + +var manager = new WorkbookManager(); +var workbook = manager.GenerateSingleQueryWorkbook( + query: new QueryInfo { QueryMashup = "...", RefreshOnOpen = true }, + initialDataGrid: grid, + fileConfiguration: new FileConfiguration()); +``` + +
+ | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `query` | [`QueryInfo`](#queryinfo) | βœ… **Required** | Power Query configuration | @@ -277,6 +490,9 @@ async function generateSingleQueryWorkbook( #### πŸ“‹ `generateTableWorkbookFromHtml()` Convert HTML tables to Excel workbooks instantly. +
+TypeScript + ```typescript async function generateTableWorkbookFromHtml( htmlTable: HTMLTableElement, @@ -284,6 +500,19 @@ async function generateTableWorkbookFromHtml( ): Promise ``` +
+ +
+.NET + +> The .NET SDK does not parse HTML tables directly. Convert the incoming data to a `Grid` and call `WorkbookManager.GenerateTableWorkbookFromGrid` as shown above. + +```csharp +var workbook = manager.GenerateTableWorkbookFromGrid(grid); +``` + +
+ | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `htmlTable` | `HTMLTableElement` | βœ… **Required** | Source HTML table | @@ -292,6 +521,9 @@ async function generateTableWorkbookFromHtml( #### πŸ“Š `generateTableWorkbookFromGrid()` Transform raw data arrays into formatted Excel tables. +
+TypeScript + ```typescript async function generateTableWorkbookFromGrid( grid: Grid, @@ -299,6 +531,21 @@ async function generateTableWorkbookFromGrid( ): Promise ``` +
+ +
+.NET + +```csharp +using Microsoft.ConnectedWorkbooks; +using Microsoft.ConnectedWorkbooks.Models; + +var manager = new WorkbookManager(); +var workbook = manager.GenerateTableWorkbookFromGrid(grid, fileConfiguration); +``` + +
+ | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `grid` | [`Grid`](#grid) | βœ… **Required** | Data and configuration | @@ -307,6 +554,9 @@ async function generateTableWorkbookFromGrid( #### 🌐 `openInExcelWeb()` Open workbooks directly in Excel for the Web. +
+TypeScript + ```typescript async function openInExcelWeb( blob: Blob, @@ -315,6 +565,23 @@ async function openInExcelWeb( ): Promise ``` +
+ +
+.NET + +> Server-side apps typically return the generated bytes as a download instead of opening Excel for the Web directly. + +```csharp +var workbook = manager.GenerateTableWorkbookFromGrid(grid); +return Results.File( + workbook, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "MyData.xlsx"); +``` + +
+ | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `blob` | `Blob` | βœ… **Required** | Generated workbook | @@ -324,13 +591,31 @@ async function openInExcelWeb( #### πŸ’Ύ `downloadWorkbook()` Trigger browser download of the workbook. +
+TypeScript + ```typescript function downloadWorkbook(file: Blob, filename: string): void ``` +
+ +
+.NET + +```csharp +var workbook = manager.GenerateTableWorkbookFromGrid(grid); +await File.WriteAllBytesAsync("MyWorkbook.xlsx", workbook); +``` + +
+ #### πŸ”— `getExcelForWebWorkbookUrl()` Get the Excel for Web URL without opening (useful for custom integrations). +
+TypeScript + ```typescript async function getExcelForWebWorkbookUrl( file: Blob, @@ -339,6 +624,20 @@ async function getExcelForWebWorkbookUrl( ): Promise ``` +
+ +
+.NET + +> The .NET SDK does not open Excel for the Web. Generate the workbook bytes and hand them to your client application (or browser) to handle navigation. + +```csharp +var workbook = manager.GenerateSingleQueryWorkbook(query); +// send workbook to the caller through your preferred channel +``` + +
+ --- ## πŸ”§ Type Definitions diff --git a/azure-pipelines-1.yml b/azure-pipelines-1.yml index 5fedbe4..77257d4 100644 --- a/azure-pipelines-1.yml +++ b/azure-pipelines-1.yml @@ -12,35 +12,65 @@ pool: steps: - task: NodeTool@0 inputs: - versionSpec: '10.x' + versionSpec: '18.x' displayName: 'Install Node.js' - script: | + cd typescript npm install npm run build - displayName: 'npm install and build' + npm run test + displayName: 'npm install, build, and test (TypeScript)' - task: Npm@1 inputs: command: 'custom' customCommand: 'pack' + workingDir: '$(Build.SourcesDirectory)/typescript' displayName: 'npm pack library' - task: CopyFiles@2 inputs: - SourceFolder: '$(Build.SourcesDirectory)' + SourceFolder: '$(Build.SourcesDirectory)/typescript' Contents: '*.tgz' - TargetFolder: '$(Build.SourcesDirectory)/out' + TargetFolder: '$(Build.ArtifactStagingDirectory)/typescript' + displayName: 'collect npm package' + +- task: UseDotNet@2 + inputs: + packageType: 'sdk' + version: '8.0.x' + displayName: 'Install .NET SDK' + +- script: | + cd dotnet + dotnet restore ConnectedWorkbooks.sln + dotnet build ConnectedWorkbooks.sln --configuration Release --no-restore + dotnet test ConnectedWorkbooks.sln --configuration Release --no-build + displayName: 'dotnet build and test' + +- task: DotNetCoreCLI@2 + displayName: 'dotnet pack' + inputs: + command: 'pack' + packagesToPack: 'dotnet/src/ConnectedWorkbooks/ConnectedWorkbooks.csproj' + configuration: 'Release' + nobuild: true + includesymbols: true + buildProperties: 'ContinuousIntegrationBuild=true' + packDirectory: '$(Build.ArtifactStagingDirectory)/nuget' - task: PublishPipelineArtifact@1 inputs: - targetPath: '$(System.DefaultWorkingDirectory)/out' - artifact: 'connected-workbooks-drop-2' + targetPath: '$(Build.ArtifactStagingDirectory)' + artifact: 'connected-workbooks-drop' publishLocation: 'pipeline' - displayName: 'publish packed library' + displayName: 'publish build artifacts' - task: Npm@1 + displayName: 'publish npm package to internal feed' inputs: command: 'publish' publishRegistry: 'useFeed' - publishFeed: '3046f601-a835-471b-8758-5953e60cb1a1/4aa42cde-db13-4c30-91ab-e4b8bd1d09f8' \ No newline at end of file + publishFeed: '3046f601-a835-471b-8758-5953e60cb1a1/4aa42cde-db13-4c30-91ab-e4b8bd1d09f8' + workingDir: '$(Build.SourcesDirectory)/typescript' \ No newline at end of file diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 5fc506b..76860f3 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -12,11 +12,25 @@ pool: steps: - task: NodeTool@0 inputs: - versionSpec: '10.x' + versionSpec: '18.x' displayName: 'Install Node.js' - script: | + cd typescript npm install npm run build npm run test - displayName: 'Build + Test' + displayName: 'Build + Test (TypeScript)' + +- task: UseDotNet@2 + inputs: + packageType: 'sdk' + version: '8.0.x' + displayName: 'Install .NET SDK' + +- script: | + cd dotnet + dotnet restore ConnectedWorkbooks.sln + dotnet build ConnectedWorkbooks.sln --configuration Release --no-restore + dotnet test ConnectedWorkbooks.sln --configuration Release --no-build + displayName: 'Build + Test (.NET)' diff --git a/dotnet/ConnectedWorkbooks.sln b/dotnet/ConnectedWorkbooks.sln new file mode 100644 index 0000000..83b9b23 --- /dev/null +++ b/dotnet/ConnectedWorkbooks.sln @@ -0,0 +1,56 @@ +ο»Ώ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConnectedWorkbooks", "src\ConnectedWorkbooks\ConnectedWorkbooks.csproj", "{5C9D0FC6-1585-4A60-B814-1A2A0B28E9D8}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConnectedWorkbooks.Tests", "tests\ConnectedWorkbooks.Tests\ConnectedWorkbooks.Tests.csproj", "{2A8D653E-7A73-4DC9-BED0-EA15DAB20D0A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5C9D0FC6-1585-4A60-B814-1A2A0B28E9D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5C9D0FC6-1585-4A60-B814-1A2A0B28E9D8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5C9D0FC6-1585-4A60-B814-1A2A0B28E9D8}.Debug|x64.ActiveCfg = Debug|Any CPU + {5C9D0FC6-1585-4A60-B814-1A2A0B28E9D8}.Debug|x64.Build.0 = Debug|Any CPU + {5C9D0FC6-1585-4A60-B814-1A2A0B28E9D8}.Debug|x86.ActiveCfg = Debug|Any CPU + {5C9D0FC6-1585-4A60-B814-1A2A0B28E9D8}.Debug|x86.Build.0 = Debug|Any CPU + {5C9D0FC6-1585-4A60-B814-1A2A0B28E9D8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5C9D0FC6-1585-4A60-B814-1A2A0B28E9D8}.Release|Any CPU.Build.0 = Release|Any CPU + {5C9D0FC6-1585-4A60-B814-1A2A0B28E9D8}.Release|x64.ActiveCfg = Release|Any CPU + {5C9D0FC6-1585-4A60-B814-1A2A0B28E9D8}.Release|x64.Build.0 = Release|Any CPU + {5C9D0FC6-1585-4A60-B814-1A2A0B28E9D8}.Release|x86.ActiveCfg = Release|Any CPU + {5C9D0FC6-1585-4A60-B814-1A2A0B28E9D8}.Release|x86.Build.0 = Release|Any CPU + {2A8D653E-7A73-4DC9-BED0-EA15DAB20D0A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2A8D653E-7A73-4DC9-BED0-EA15DAB20D0A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A8D653E-7A73-4DC9-BED0-EA15DAB20D0A}.Debug|x64.ActiveCfg = Debug|Any CPU + {2A8D653E-7A73-4DC9-BED0-EA15DAB20D0A}.Debug|x64.Build.0 = Debug|Any CPU + {2A8D653E-7A73-4DC9-BED0-EA15DAB20D0A}.Debug|x86.ActiveCfg = Debug|Any CPU + {2A8D653E-7A73-4DC9-BED0-EA15DAB20D0A}.Debug|x86.Build.0 = Debug|Any CPU + {2A8D653E-7A73-4DC9-BED0-EA15DAB20D0A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2A8D653E-7A73-4DC9-BED0-EA15DAB20D0A}.Release|Any CPU.Build.0 = Release|Any CPU + {2A8D653E-7A73-4DC9-BED0-EA15DAB20D0A}.Release|x64.ActiveCfg = Release|Any CPU + {2A8D653E-7A73-4DC9-BED0-EA15DAB20D0A}.Release|x64.Build.0 = Release|Any CPU + {2A8D653E-7A73-4DC9-BED0-EA15DAB20D0A}.Release|x86.ActiveCfg = Release|Any CPU + {2A8D653E-7A73-4DC9-BED0-EA15DAB20D0A}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {5C9D0FC6-1585-4A60-B814-1A2A0B28E9D8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {2A8D653E-7A73-4DC9-BED0-EA15DAB20D0A} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection +EndGlobal diff --git a/dotnet/sample/ConnectedWorkbooks.Sample.csproj b/dotnet/sample/ConnectedWorkbooks.Sample.csproj new file mode 100644 index 0000000..d1ebd27 --- /dev/null +++ b/dotnet/sample/ConnectedWorkbooks.Sample.csproj @@ -0,0 +1,14 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + diff --git a/dotnet/sample/Program.cs b/dotnet/sample/Program.cs new file mode 100644 index 0000000..b92433a --- /dev/null +++ b/dotnet/sample/Program.cs @@ -0,0 +1,38 @@ +using Microsoft.ConnectedWorkbooks; +using Microsoft.ConnectedWorkbooks.Models; + +var manager = new WorkbookManager(); + +var mashup = """ +let + Source = #table( + {"City","TempC"}, + { + {"Seattle", 18}, + {"London", 15}, + {"Sydney", 22} + } + ) +in + Source +"""; + +var query = new QueryInfo( + queryMashup: mashup, + queryName: "WeatherSample", + refreshOnOpen: false); + +var grid = new Grid(new[] +{ + new object?[] { "City", "TempC" }, + new object?[] { "Seattle", 0 }, + new object?[] { "London", 0 }, + new object?[] { "Sydney", 0 } +}, new GridConfig { PromoteHeaders = true }); + +var bytes = manager.GenerateSingleQueryWorkbook(query, grid); +var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..")); +var outputPath = Path.Combine(repoRoot, "WeatherSample.xlsx"); +await File.WriteAllBytesAsync(outputPath, bytes); + +Console.WriteLine($"Workbook generated: {outputPath}"); diff --git a/dotnet/src/ConnectedWorkbooks/ConnectedWorkbooks.csproj b/dotnet/src/ConnectedWorkbooks/ConnectedWorkbooks.csproj new file mode 100644 index 0000000..2fbe021 --- /dev/null +++ b/dotnet/src/ConnectedWorkbooks/ConnectedWorkbooks.csproj @@ -0,0 +1,46 @@ +ο»Ώ + + + net8.0 + enable + enable + true + Microsoft + Microsoft + Microsoft.ConnectedWorkbooks + Connected Workbooks SDK (.NET) + Generate Excel workbooks with Power Query connections, branded templates, and tabular data using .NET. + Excel;Power Query;OpenXML;Workbook + https://github.com/microsoft/connected-workbooks + https://github.com/microsoft/connected-workbooks + git + MIT + false + true + true + snupkg + true + true + $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb + ../README.md + + + + + + + + + + + + + + + + + diff --git a/dotnet/src/ConnectedWorkbooks/Internal/ArrayReader.cs b/dotnet/src/ConnectedWorkbooks/Internal/ArrayReader.cs new file mode 100644 index 0000000..673cb20 --- /dev/null +++ b/dotnet/src/ConnectedWorkbooks/Internal/ArrayReader.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System.Buffers.Binary; + +namespace Microsoft.ConnectedWorkbooks.Internal; + +/// +/// Lightweight helper for sequentially reading primitive values from a byte buffer. +/// +internal sealed class ArrayReader +{ + private readonly ReadOnlyMemory _buffer; + private int _offset; + + /// + /// Initializes a new reader that iterates over the supplied byte array. + /// + /// The byte array to consume. + public ArrayReader(byte[] buffer) + : this(new ReadOnlyMemory(buffer)) + { + } + + /// + /// Initializes a new reader for the provided byte memory segment. + /// + /// The data segment to consume. + public ArrayReader(ReadOnlyMemory buffer) + { + _buffer = buffer; + _offset = 0; + } + + /// + /// Reads the specified number of bytes, advancing the reader past them. + /// + /// Number of bytes to read. + /// The requested slice of the underlying buffer. + public ReadOnlyMemory ReadMemory(int count) + { + EnsureAvailable(count); + var slice = _buffer.Slice(_offset, count); + _offset += count; + return slice; + } + + /// + /// Reads a 32-bit little-endian integer from the buffer. + /// + /// The parsed integer. + public int ReadInt32() + { + EnsureAvailable(sizeof(int)); + var value = BinaryPrimitives.ReadInt32LittleEndian(_buffer.Span.Slice(_offset, sizeof(int))); + _offset += sizeof(int); + return value; + } + + /// + /// Returns the remaining bytes from the current position and advances to the end. + /// + /// A slice containing all remaining bytes. + public ReadOnlyMemory ReadToEnd() + { + var slice = _buffer.Slice(_offset); + _offset = _buffer.Length; + return slice; + } + + private void EnsureAvailable(int count) + { + if (_offset + count > _buffer.Length) + { + throw new InvalidOperationException("Attempted to read beyond the length of the buffer."); + } + } +} + diff --git a/dotnet/src/ConnectedWorkbooks/Internal/CellReferenceHelper.cs b/dotnet/src/ConnectedWorkbooks/Internal/CellReferenceHelper.cs new file mode 100644 index 0000000..7544b50 --- /dev/null +++ b/dotnet/src/ConnectedWorkbooks/Internal/CellReferenceHelper.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace Microsoft.ConnectedWorkbooks.Internal; + +/// +/// Utility methods for converting between Excel-style cell references and numeric coordinates. +/// +internal static class CellReferenceHelper +{ + /// + /// Returns the zero-based row/column tuple that represents the starting cell in the reference. + /// + /// A cell reference such as "A1" or "A1:B5". + /// A tuple containing one-based row and column coordinates. + public static (int Row, int Column) GetStartPosition(string reference) + { + // Reference format "A1" or "A1:B5"; we only care about the first cell + var start = reference.Split(':')[0]; + var letters = new string(start.TakeWhile(char.IsLetter).ToArray()); + var digits = new string(start.SkipWhile(char.IsLetter).ToArray()); + var column = ColumnNameToNumber(letters); + var row = int.TryParse(digits, out var parsedRow) ? parsedRow : 1; + return (row, column); + } + + /// + /// Converts a zero-based column index into its Excel column name. + /// + /// Zero-based column index. + /// The Excel column label (e.g. 0 -> "A"). + public static string ColumnNumberToName(int columnIndex) + { + columnIndex++; // zero-based to one-based + var columnName = string.Empty; + while (columnIndex > 0) + { + var remainder = (columnIndex - 1) % 26; + columnName = (char)('A' + remainder) + columnName; + columnIndex = (columnIndex - remainder - 1) / 26; + } + + return columnName; + } + + /// + /// Builds a rectangular range reference given a starting coordinate and bounds. + /// + /// One-based starting position. + /// Number of columns in the range. + /// Number of rows in the range. + /// The Excel range reference spanning the requested area. + public static string BuildReference((int Row, int Column) start, int columnCount, int rowCount) + { + var endColumnIndex = start.Column - 1 + columnCount; + var endRow = start.Row - 1 + rowCount; + var startRef = $"{ColumnNumberToName(start.Column - 1)}{start.Row}"; + var endRef = $"{ColumnNumberToName(endColumnIndex - 1)}{endRow}"; + return $"{startRef}:{endRef}"; + } + + /// + /// Converts the provided range into an absolute reference with an optional sheet prefix. + /// + /// The relative range reference. + /// Optional sheet prefix (for example 'Sheet1'). + /// The absolute equivalent (e.g. 'Sheet1'!$A$1:$B$2). + public static string WithAbsolute(string reference, string? sheetName = null) + { + var (row, column) = GetStartPosition(reference); + var (endRow, endColumn) = GetEndPosition(reference); + var prefix = string.IsNullOrEmpty(sheetName) ? string.Empty : $"{sheetName}!"; + return $"{prefix}${ColumnNumberToName(column - 1)}${row}:${ColumnNumberToName(endColumn - 1)}${endRow}"; + } + + private static (int Row, int Column) GetEndPosition(string reference) + { + var parts = reference.Split(':'); + var target = parts.Length == 2 ? parts[1] : parts[0]; + var letters = new string(target.TakeWhile(char.IsLetter).ToArray()); + var digits = new string(target.SkipWhile(char.IsLetter).ToArray()); + var column = ColumnNameToNumber(letters); + var row = int.TryParse(digits, out var parsedRow) ? parsedRow : 1; + return (row, column); + } + + private static int ColumnNameToNumber(string columnName) + { + if (string.IsNullOrWhiteSpace(columnName)) + { + return 1; + } + + var result = 0; + foreach (var ch in columnName.ToUpperInvariant()) + { + result = result * 26 + (ch - 'A' + 1); + } + + return result; + } +} + diff --git a/dotnet/src/ConnectedWorkbooks/Internal/EmbeddedTemplateLoader.cs b/dotnet/src/ConnectedWorkbooks/Internal/EmbeddedTemplateLoader.cs new file mode 100644 index 0000000..38e7f93 --- /dev/null +++ b/dotnet/src/ConnectedWorkbooks/Internal/EmbeddedTemplateLoader.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System.Reflection; + +namespace Microsoft.ConnectedWorkbooks.Internal; + +/// +/// Provides access to the workbook templates embedded in the assembly resources. +/// +internal static class EmbeddedTemplateLoader +{ + private const string ResourcePrefix = "ConnectedWorkbooks.Templates."; + private const string SimpleQueryTemplateResource = ResourcePrefix + "SIMPLE_QUERY_WORKBOOK_TEMPLATE.xlsx"; + private const string BlankTableTemplateResource = ResourcePrefix + "SIMPLE_BLANK_TABLE_TEMPLATE.xlsx"; + private static readonly Lazy SimpleQueryTemplate = new(() => LoadTemplateFromManifest(SimpleQueryTemplateResource)); + private static readonly Lazy BlankTableTemplate = new(() => LoadTemplateFromManifest(BlankTableTemplateResource)); + + /// + /// Returns the cached bytes for the single-query workbook template. + /// + public static byte[] LoadSimpleQueryTemplate() => + SimpleQueryTemplate.Value; + + /// + /// Returns the cached bytes for the blank table workbook template. + /// + public static byte[] LoadBlankTableTemplate() => + BlankTableTemplate.Value; + + /// + /// Reads an embedded resource stream and returns its contents as a byte array. + /// + /// Fully qualified manifest resource name. + /// The resource contents. + private static byte[] LoadTemplateFromManifest(string resourceName) + { + var assembly = Assembly.GetExecutingAssembly(); + using var stream = assembly.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException($"Unable to locate embedded template '{resourceName}'."); + using var memory = new MemoryStream(); + stream.CopyTo(memory); + return memory.ToArray(); + } +} + diff --git a/dotnet/src/ConnectedWorkbooks/Internal/ExcelArchive.cs b/dotnet/src/ConnectedWorkbooks/Internal/ExcelArchive.cs new file mode 100644 index 0000000..c4926b7 --- /dev/null +++ b/dotnet/src/ConnectedWorkbooks/Internal/ExcelArchive.cs @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System.IO.Compression; +using System.Text; + +namespace Microsoft.ConnectedWorkbooks.Internal; + +/// +/// Thin wrapper around that simplifies editing workbook parts in memory. +/// +internal sealed class ExcelArchive : IDisposable +{ + private readonly MemoryStream _stream; + private ZipArchive? _zipArchive; + private bool _disposed; + + private ExcelArchive(byte[] template) + { + _stream = new MemoryStream(); + _stream.Write(template, 0, template.Length); + _stream.Position = 0; + _zipArchive = new ZipArchive(_stream, ZipArchiveMode.Update, leaveOpen: true); + } + + /// + /// Loads the supplied workbook template bytes into an editable archive. + /// + /// The XLSX template to load. + /// An ready for manipulation. + public static ExcelArchive Load(byte[] template) => new(template); + + /// + /// Serializes the in-memory workbook back into a byte array. + /// + /// The workbook bytes. + public byte[] ToArray() + { + _zipArchive?.Dispose(); + _zipArchive = null; + return _stream.ToArray(); + } + + /// + /// Reads the contents of the specified part as UTF-8 text. + /// + /// Part path inside the archive. + /// File contents. + public string ReadText(string path) + { + var entry = GetEntry(path); + using var stream = entry.Open(); + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: false); + return reader.ReadToEnd(); + } + + /// + /// Writes text into the specified part, truncating existing data. + /// + /// Part path inside the archive. + /// Text to persist. + /// Optional encoding (defaults to UTF-8 without BOM). + public void WriteText(string path, string content, Encoding? encoding = null) + { + var entry = GetOrCreateEntry(path); + using var stream = entry.Open(); + stream.SetLength(0); + using var writer = new StreamWriter(stream, encoding ?? new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), leaveOpen: true); + writer.Write(content); + writer.Flush(); + } + + /// + /// Writes raw bytes into the specified part, truncating existing data. + /// + /// Part path inside the archive. + /// Data to persist. + public void WriteBytes(string path, byte[] content) + { + var entry = GetOrCreateEntry(path); + using var stream = entry.Open(); + stream.SetLength(0); + stream.Write(content, 0, content.Length); + } + + /// + /// Enumerates entries that reside under the provided folder prefix. + /// + /// Folder prefix, e.g. xl/tables/. + /// Paths of matching entries. + public IEnumerable EnumerateEntries(string folderPrefix) + { + EnsureNotDisposed(); + + foreach (var entry in _zipArchive!.Entries) + { + if (entry.FullName.StartsWith(folderPrefix, StringComparison.OrdinalIgnoreCase)) + { + yield return entry.FullName; + } + } + } + + /// + /// Indicates whether the specified entry exists within the archive. + /// + /// Part path inside the archive. + public bool EntryExists(string path) + { + EnsureNotDisposed(); + return _zipArchive!.GetEntry(path) is not null; + } + + /// + /// Removes the specified entry if present. + /// + /// Part path inside the archive. + public void Remove(string path) + { + EnsureNotDisposed(); + _zipArchive!.GetEntry(path)?.Delete(); + } + + private ZipArchiveEntry GetEntry(string path) + { + EnsureNotDisposed(); + return _zipArchive?.GetEntry(path) ?? throw new InvalidOperationException($"'{path}' was not found inside the workbook template."); + } + + private ZipArchiveEntry GetOrCreateEntry(string path) + { + EnsureNotDisposed(); + return _zipArchive?.GetEntry(path) ?? _zipArchive!.CreateEntry(path, CompressionLevel.Optimal); + } + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _zipArchive?.Dispose(); + _zipArchive = null; + _stream.Dispose(); + } + + private void EnsureNotDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(ExcelArchive)); + } + } +} + diff --git a/dotnet/src/ConnectedWorkbooks/Internal/GridParser.cs b/dotnet/src/ConnectedWorkbooks/Internal/GridParser.cs new file mode 100644 index 0000000..de5223b --- /dev/null +++ b/dotnet/src/ConnectedWorkbooks/Internal/GridParser.cs @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using Microsoft.ConnectedWorkbooks.Models; + +namespace Microsoft.ConnectedWorkbooks.Internal; + +/// +/// Converts the public abstraction into normalized instances. +/// +internal static class GridParser +{ + /// + /// Normalizes the supplied grid into a form that can be written to Excel. + /// + /// Grid data supplied by the caller. + /// A normalized instance. + public static TableData Parse(Grid grid) + { + grid ??= new Grid(Array.Empty>()); + var data = grid.Data?.Select(row => row.Select(value => value?.ToString() ?? string.Empty).ToArray()).ToList() + ?? new List { Array.Empty() }; + + var promoteHeaders = grid.Config?.PromoteHeaders ?? false; + var adjustColumns = grid.Config?.AdjustColumnNames ?? true; + + CorrectGrid(data, ref promoteHeaders); + ValidateGrid(data, promoteHeaders, adjustColumns); + + string[] columnNames; + if (promoteHeaders && adjustColumns) + { + columnNames = AdjustColumnNames(data[0]); + data.RemoveAt(0); + } + else if (promoteHeaders) + { + columnNames = data[0]; + data.RemoveAt(0); + } + else + { + columnNames = Enumerable.Range(1, data[0].Length).Select(i => $"Column {i}").ToArray(); + } + + return new TableData(columnNames, data); + } + + private static void CorrectGrid(IList data, ref bool promoteHeaders) + { + if (data.Count == 0) + { + promoteHeaders = false; + data.Add([string.Empty]); + return; + } + + if (data[0].Length == 0) + { + data[0] = [string.Empty]; + } + + var width = data[0].Length; + for (var i = 0; i < data.Count; i++) + { + if (data[i].Length == 0) + { + data[i] = Enumerable.Repeat(string.Empty, width).ToArray(); + } + } + + if (promoteHeaders && data.Count == 1) + { + data.Add(Enumerable.Repeat(string.Empty, width).ToArray()); + } + } + + private static void ValidateGrid(IReadOnlyList data, bool promoteHeaders, bool adjustColumns) + { + if (data.Count == 0 || data[0].Length == 0) + { + throw new InvalidOperationException("The provided grid is empty."); + } + + if (data.Any(row => row.Length != data[0].Length)) + { + throw new InvalidOperationException("The provided grid is not a rectangular MxN matrix."); + } + + if (promoteHeaders && !adjustColumns) + { + if (data[0].Any(string.IsNullOrWhiteSpace)) + { + throw new InvalidOperationException("Headers cannot be promoted when empty values exist."); + } + + var uniqueCount = data[0].Select(name => name.ToLowerInvariant()).Distinct().Count(); + if (uniqueCount != data[0].Length) + { + throw new InvalidOperationException("Headers must be unique when column adjustments are disabled."); + } + } + } + + private static string[] AdjustColumnNames(string[] columnNames) + { + var unique = new HashSet(StringComparer.OrdinalIgnoreCase); + var result = new string[columnNames.Length]; + for (var i = 0; i < columnNames.Length; i++) + { + var baseName = string.IsNullOrWhiteSpace(columnNames[i]) ? $"Column {i + 1}" : columnNames[i]; + var candidate = baseName; + var suffix = 1; + while (!unique.Add(candidate)) + { + candidate = $"{baseName} ({suffix++})"; + } + + result[i] = candidate; + } + + return result; + } +} + diff --git a/dotnet/src/ConnectedWorkbooks/Internal/MashupDocumentParser.cs b/dotnet/src/ConnectedWorkbooks/Internal/MashupDocumentParser.cs new file mode 100644 index 0000000..e985846 --- /dev/null +++ b/dotnet/src/ConnectedWorkbooks/Internal/MashupDocumentParser.cs @@ -0,0 +1,249 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.Buffers.Binary; +using System.IO.Compression; +using System.Text; +using System.Xml.Linq; + +namespace Microsoft.ConnectedWorkbooks.Internal; + +/// +/// Handles low-level editing of the Power Query (DataMashup) payload inside workbook templates. +/// +internal static class MashupDocumentParser +{ + /// + /// Replaces the contents of the Section1.m formula with the provided mashup and updates metadata accordingly. + /// + /// Original DataMashup payload encoded as Base64. + /// Friendly query name that should be referenced throughout metadata. + /// New M document to write into Section1.m. + /// A base64 string with the updated mashup payload. + public static string ReplaceSingleQuery(string base64, string queryName, string queryMashupDocument) + { + var buffer = Convert.FromBase64String(base64); + var reader = new ArrayReader(buffer); + var versionBytes = reader.ReadMemory(4); + var packageSize = reader.ReadInt32(); + var packageOpc = reader.ReadMemory(packageSize); + var permissionsSize = reader.ReadInt32(); + var permissions = reader.ReadMemory(permissionsSize); + var metadataSize = reader.ReadInt32(); + var metadataBytes = reader.ReadMemory(metadataSize); + var endBuffer = reader.ReadToEnd(); + + var newPackage = EditSingleQueryPackage(packageOpc.Span, queryMashupDocument); + var newMetadata = EditSingleQueryMetadata(metadataBytes, queryName); + + var totalLength = versionBytes.Length + + sizeof(int) + + newPackage.Length + + sizeof(int) + + permissions.Length + + sizeof(int) + + newMetadata.Length + + endBuffer.Length; + + var finalBytes = new byte[totalLength]; + var writer = new SpanWriter(finalBytes); + writer.WriteBytes(versionBytes.Span); + writer.WriteLength(newPackage.Length); + writer.WriteBytes(newPackage); + writer.WriteLength(permissionsSize); + writer.WriteBytes(permissions.Span); + writer.WriteLength(newMetadata.Length); + writer.WriteBytes(newMetadata); + writer.WriteBytes(endBuffer.Span); + + return Convert.ToBase64String(finalBytes); + } + + /// + /// Extracts the first query name referenced in the template's metadata. + /// + /// Base64-encoded DataMashup payload. + /// The query name referenced by Section1 (defaults to Query1 when missing). + public static string GetPrimaryQueryName(string base64) + { + var buffer = Convert.FromBase64String(base64); + var reader = new ArrayReader(buffer); + reader.ReadMemory(4); // version + var packageSize = reader.ReadInt32(); + reader.ReadMemory(packageSize); + var permissionsSize = reader.ReadInt32(); + reader.ReadMemory(permissionsSize); + var metadataSize = reader.ReadInt32(); + var metadataBytes = reader.ReadMemory(metadataSize); + + var metadataReader = new ArrayReader(metadataBytes); + metadataReader.ReadMemory(4); // metadata version + var metadataXmlSize = metadataReader.ReadInt32(); + var metadataXmlBytes = metadataReader.ReadMemory(metadataXmlSize); + + var doc = ParseMetadataDocument(metadataXmlBytes); + return ExtractQueryName(doc) ?? WorkbookConstants.DefaultQueryName; + } + + private static byte[] EditSingleQueryPackage(ReadOnlySpan packageOpc, string queryMashupDocument) + { + using var packageStream = new MemoryStream(); + packageStream.Write(packageOpc); + packageStream.Position = 0; + using var zip = new ZipArchive(packageStream, ZipArchiveMode.Update, leaveOpen: true); + var entry = zip.GetEntry(WorkbookConstants.Section1mPath) + ?? throw new InvalidOperationException("Formula section was not found in the Power Query package."); + + using (var entryStream = entry.Open()) + using (var writer = new StreamWriter(entryStream, new UTF8Encoding(false), leaveOpen: true)) + { + entryStream.SetLength(0); + writer.Write(queryMashupDocument); + writer.Flush(); + } + + zip.Dispose(); + return packageStream.ToArray(); + } + + private static byte[] EditSingleQueryMetadata(ReadOnlyMemory metadataBytes, string queryName) + { + var reader = new ArrayReader(metadataBytes); + var metadataVersion = reader.ReadMemory(4); + var metadataXmlSize = reader.ReadInt32(); + var metadataXmlBytes = reader.ReadMemory(metadataXmlSize); + var endBuffer = reader.ReadToEnd(); + + var metadataDoc = ParseMetadataDocument(metadataXmlBytes); + UpdateMetadataDocument(metadataDoc, queryName); + + var newMetadataXml = Encoding.UTF8.GetBytes(metadataDoc.ToString(SaveOptions.DisableFormatting)); + + var totalLength = metadataVersion.Length + sizeof(int) + newMetadataXml.Length + endBuffer.Length; + var buffer = new byte[totalLength]; + var writer = new SpanWriter(buffer); + writer.WriteBytes(metadataVersion.Span); + writer.WriteLength(newMetadataXml.Length); + writer.WriteBytes(newMetadataXml); + writer.WriteBytes(endBuffer.Span); + + return buffer; + } + + private static XDocument ParseMetadataDocument(ReadOnlyMemory metadataXmlBytes) + { + var metadataXmlString = Encoding.UTF8.GetString(metadataXmlBytes.Span).TrimStart('\uFEFF'); + try + { + return XDocument.Parse(metadataXmlString, LoadOptions.PreserveWhitespace); + } + catch (Exception ex) + { + var preview = Convert.ToHexString(metadataXmlBytes.Span[..Math.Min(metadataXmlBytes.Length, 64)]); + throw new InvalidOperationException($"Failed to parse metadata XML. Hex preview: {preview}", ex); + } + } + + private static string? ExtractQueryName(XDocument doc) + { + if (doc.Root is null) + { + return null; + } + + foreach (var itemPathElement in doc.Descendants().Where(e => e.Name.LocalName == XmlNames.Elements.ItemPath)) + { + var content = itemPathElement.Value; + if (!content.Contains("Section1/", StringComparison.Ordinal)) + { + continue; + } + + var parts = content.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2) + { + continue; + } + + return Uri.UnescapeDataString(parts[1]); + } + + return null; + } + + private static void UpdateMetadataDocument(XDocument doc, string queryName) + { + if (!string.IsNullOrWhiteSpace(queryName)) + { + RenameItemPaths(doc, queryName); + } + + var now = DateTime.UtcNow.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff", System.Globalization.CultureInfo.InvariantCulture); + var lastUpdatedValue = $"d{now}0000Z"; + + foreach (var entry in doc.Descendants().Where(e => e.Name.LocalName == XmlNames.Elements.Entry)) + { + var typeValue = entry.Attribute(XmlNames.Attributes.Type)?.Value; + if (string.Equals(typeValue, "ResultType", StringComparison.Ordinal)) + { + entry.SetAttributeValue(XmlNames.Attributes.Value, "sTable"); + } + else if (string.Equals(typeValue, "FillLastUpdated", StringComparison.Ordinal)) + { + entry.SetAttributeValue(XmlNames.Attributes.Value, lastUpdatedValue); + } + } + } + + private static void RenameItemPaths(XDocument doc, string queryName) + { + foreach (var itemPathElement in doc.Descendants().Where(e => e.Name.LocalName == XmlNames.Elements.ItemPath)) + { + var content = itemPathElement.Value; + if (!content.Contains("Section1/", StringComparison.Ordinal)) + { + continue; + } + + var parts = content.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2) + { + continue; + } + + parts[1] = Uri.EscapeDataString(queryName); + itemPathElement.Value = string.Join('/', parts); + } + } + + private ref struct SpanWriter + { + private readonly Span _destination; + private int _offset; + + public SpanWriter(Span destination) + { + _destination = destination; + _offset = 0; + } + + public void WriteBytes(ReadOnlySpan source) + { + if (source.Length == 0) + { + return; + } + + source.CopyTo(_destination[_offset..]); + _offset += source.Length; + } + + public void WriteLength(int value) + { + BinaryPrimitives.WriteInt32LittleEndian(_destination.Slice(_offset, sizeof(int)), value); + _offset += sizeof(int); + } + } +} + diff --git a/dotnet/src/ConnectedWorkbooks/Internal/PowerQueryGenerator.cs b/dotnet/src/ConnectedWorkbooks/Internal/PowerQueryGenerator.cs new file mode 100644 index 0000000..88f1069 --- /dev/null +++ b/dotnet/src/ConnectedWorkbooks/Internal/PowerQueryGenerator.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace Microsoft.ConnectedWorkbooks.Internal; + +/// +/// Generates Power Query (M) documents used by the workbook templates. +/// +internal static class PowerQueryGenerator +{ + /// + /// Creates a Section1.m document that exposes a single shared query with the supplied body. + /// + /// Name of the query to generate. + /// M script that defines the query. + /// The complete M document ready to embed. + public static string GenerateSingleQueryMashup(string queryName, string queryBody) + { + return $"section Section1;\n\nshared #\"{queryName}\" = \n{queryBody};"; + } +} + diff --git a/dotnet/src/ConnectedWorkbooks/Internal/PqUtilities.cs b/dotnet/src/ConnectedWorkbooks/Internal/PqUtilities.cs new file mode 100644 index 0000000..4bc8d92 --- /dev/null +++ b/dotnet/src/ConnectedWorkbooks/Internal/PqUtilities.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System.Text; +using System.Xml.Linq; + +namespace Microsoft.ConnectedWorkbooks.Internal; + +/// +/// Helpers for interacting with the Power Query (DataMashup) parts inside workbook templates. +/// +internal static class PqUtilities +{ + /// + /// Locates the DataMashup XML inside the workbook and returns its location and payload. + /// + /// Workbook archive to inspect. + /// The entry path and base64 payload. + public static (string Path, string Base64) GetDataMashup(ExcelArchive archive) + { + foreach (var entryPath in archive.EnumerateEntries(WorkbookConstants.CustomXmlFolder)) + { + var match = WorkbookConstants.CustomXmlItemRegex.Match(entryPath); + if (!match.Success) + { + continue; + } + + var xml = archive.ReadText(entryPath); + var doc = XDocument.Parse(xml, LoadOptions.PreserveWhitespace); + if (!string.Equals(doc.Root?.Name.NamespaceName, WorkbookConstants.DataMashupNamespace, StringComparison.Ordinal)) + { + continue; + } + + var base64 = doc.Root?.Value ?? throw new InvalidOperationException("DataMashup element was empty."); + return (entryPath, base64); + } + + throw new InvalidOperationException("DataMashup XML was not found in the workbook template."); + } + + /// + /// Writes the provided DataMashup payload back into the workbook. + /// + /// Workbook archive to mutate. + /// Entry path returned by . + /// Base64-encoded payload that should replace the current content. + public static void SetDataMashup(ExcelArchive archive, string path, string base64) + { + var xml = $"{base64}"; + var encoded = Encoding.Unicode.GetBytes("\uFEFF" + xml); + archive.WriteBytes(path, encoded); + } + + /// + /// Validates that a query name conforms to Excel's constraints. + /// + /// The user supplied query name. + /// Thrown when the name violates naming rules. + public static void ValidateQueryName(string queryName) + { + if (string.IsNullOrWhiteSpace(queryName)) + { + throw new ArgumentException("Query name cannot be empty.", nameof(queryName)); + } + + if (queryName.Length > WorkbookConstants.MaxQueryLength) + { + throw new ArgumentException($"Query names are limited to {WorkbookConstants.MaxQueryLength} characters.", nameof(queryName)); + } + + if (queryName.Any(ch => ch == '"' || ch == '.' || char.IsControl(ch))) + { + throw new ArgumentException("Query names cannot contain periods, quotes, or control characters.", nameof(queryName)); + } + } +} + diff --git a/dotnet/src/ConnectedWorkbooks/Internal/QueryNameValidator.cs b/dotnet/src/ConnectedWorkbooks/Internal/QueryNameValidator.cs new file mode 100644 index 0000000..2f99c63 --- /dev/null +++ b/dotnet/src/ConnectedWorkbooks/Internal/QueryNameValidator.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; + +namespace Microsoft.ConnectedWorkbooks.Internal; + +/// +/// Provides validation and normalization helpers for user-supplied query names. +/// +internal static class QueryNameValidator +{ + /// + /// Normalizes a user-supplied query name, applying defaults and validation. + /// + /// The original query name provided by the caller. + /// A validated query name. + /// Thrown when the name does not meet requirements. + public static string Resolve(string? candidate) + { + var effectiveName = string.IsNullOrWhiteSpace(candidate) + ? WorkbookConstants.DefaultQueryName + : candidate.Trim(); + + Validate(effectiveName); + return effectiveName; + } + + /// + /// Validates a query name using the same constraints as the TypeScript implementation. + /// + /// Name to validate. + /// Thrown when the name does not meet requirements. + public static void Validate(string queryName) + { + if (string.IsNullOrWhiteSpace(queryName)) + { + throw new ArgumentException("Query name cannot be empty.", nameof(queryName)); + } + + if (queryName.Length > WorkbookConstants.MaxQueryLength) + { + throw new ArgumentException($"Query names are limited to {WorkbookConstants.MaxQueryLength} characters.", nameof(queryName)); + } + + foreach (var ch in queryName) + { + if (ch == '"' || ch == '.' || IsControlCharacter(ch)) + { + throw new ArgumentException("Query name contains invalid characters.", nameof(queryName)); + } + } + } + + private static bool IsControlCharacter(char value) + { + return (value >= '\u0000' && value <= '\u001F') + || (value >= '\u007F' && value <= '\u009F'); + } +} diff --git a/dotnet/src/ConnectedWorkbooks/Internal/TemplateMetadataResolver.cs b/dotnet/src/ConnectedWorkbooks/Internal/TemplateMetadataResolver.cs new file mode 100644 index 0000000..bec5958 --- /dev/null +++ b/dotnet/src/ConnectedWorkbooks/Internal/TemplateMetadataResolver.cs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System.Linq; +using System.Xml.Linq; +using Microsoft.ConnectedWorkbooks.Models; + +namespace Microsoft.ConnectedWorkbooks.Internal; + +/// +/// Represents the worksheet/table components extracted from a workbook template. +/// +/// Path to the worksheet part that should receive data. +/// Path to the table definition part. +/// One-based coordinates describing where the table begins. +internal sealed record TemplateMetadata(string WorksheetPath, string TablePath, (int Row, int Column) TableStart); + +/// +/// Resolves template metadata (worksheet/table paths and coordinates) for built-in or custom templates. +/// +internal static class TemplateMetadataResolver +{ + private static readonly XNamespace RelationshipsNamespace = "http://schemas.openxmlformats.org/package/2006/relationships"; + private static readonly XNamespace OfficeRelationshipsNamespace = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + + /// + /// Resolves worksheet/table information for the provided template, honoring optional overrides. + /// + /// Workbook archive to inspect. + /// Optional overrides that specify sheet/table names. + /// A instance describing the target sheet/table. + public static TemplateMetadata Resolve(ExcelArchive archive, TemplateSettings? templateSettings) + { + var worksheetPath = ResolveWorksheetPath(archive, templateSettings?.SheetName); + var (tablePath, tableStart) = ResolveTablePath(archive, templateSettings?.TableName); + return new TemplateMetadata(worksheetPath, tablePath, tableStart); + } + + private static string ResolveWorksheetPath(ExcelArchive archive, string? sheetName) + { + if (string.IsNullOrWhiteSpace(sheetName)) + { + if (!archive.EntryExists(WorkbookConstants.DefaultSheetPath)) + { + throw new InvalidOperationException("The workbook template does not contain the default worksheet 'xl/worksheets/sheet1.xml'. Provide FileConfiguration.TemplateSettings.SheetName to indicate the target sheet."); + } + + return WorkbookConstants.DefaultSheetPath; + } + + var workbookXml = archive.ReadText(WorkbookConstants.WorkbookXmlPath); + var workbookDoc = XDocument.Parse(workbookXml, LoadOptions.PreserveWhitespace); + var workbookNs = workbookDoc.Root?.Name.Namespace ?? XNamespace.None; + var sheetElement = workbookDoc + .Descendants(workbookNs + "sheet") + .FirstOrDefault(node => string.Equals(node.Attribute("name")?.Value, sheetName, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException($"Worksheet '{sheetName}' was not found in the workbook template."); + + var relationshipId = sheetElement.Attribute(OfficeRelationshipsNamespace + "id")?.Value + ?? throw new InvalidOperationException($"Worksheet '{sheetName}' is missing the relationship id attribute."); + + var relsXml = archive.ReadText(WorkbookConstants.WorkbookRelsPath); + var relsDoc = XDocument.Parse(relsXml, LoadOptions.PreserveWhitespace); + var relationship = relsDoc + .Descendants(RelationshipsNamespace + XmlNames.Elements.Relationship) + .FirstOrDefault(node => string.Equals(node.Attribute("Id")?.Value, relationshipId, StringComparison.Ordinal)) + ?? throw new InvalidOperationException($"Relationship '{relationshipId}' referenced by worksheet '{sheetName}' was not found."); + + var target = relationship.Attribute("Target")?.Value + ?? throw new InvalidOperationException($"Relationship '{relationshipId}' does not contain a target path."); + + return NormalizePath(target); + } + + private static (string Path, (int Row, int Column) Start) ResolveTablePath(ExcelArchive archive, string? tableName) + { + if (!string.IsNullOrWhiteSpace(tableName)) + { + foreach (var entryPath in archive.EnumerateEntries(WorkbookConstants.TablesFolder)) + { + var metadata = ReadTableMetadata(archive.ReadText(entryPath)); + if (string.Equals(metadata.Name, tableName, StringComparison.OrdinalIgnoreCase)) + { + return (entryPath, metadata.Start); + } + } + + throw new InvalidOperationException($"Table '{tableName}' was not found in the workbook template."); + } + + if (!archive.EntryExists(WorkbookConstants.DefaultTablePath)) + { + throw new InvalidOperationException("The workbook template does not contain the default table 'xl/tables/table1.xml'. Provide FileConfiguration.TemplateSettings.TableName to indicate which table to target."); + } + + var defaultMetadata = ReadTableMetadata(archive.ReadText(WorkbookConstants.DefaultTablePath)); + return (WorkbookConstants.DefaultTablePath, defaultMetadata.Start); + } + + private static (string Name, (int Row, int Column) Start) ReadTableMetadata(string tableXml) + { + var doc = XDocument.Parse(tableXml, LoadOptions.PreserveWhitespace); + var name = doc.Root?.Attribute("name")?.Value + ?? throw new InvalidOperationException("Table definition is missing the 'name' attribute."); + var reference = doc.Root?.Attribute("ref")?.Value ?? "A1"; + var cleanedReference = reference.Replace("$", string.Empty, StringComparison.Ordinal); + var start = CellReferenceHelper.GetStartPosition(cleanedReference); + return (name, start); + } + + private static string NormalizePath(string target) + { + var normalized = target.Replace('\\', '/'); + if (normalized.StartsWith("/", StringComparison.Ordinal)) + { + return $"xl{normalized}"; + } + + return $"xl/{normalized}"; + } +} diff --git a/dotnet/src/ConnectedWorkbooks/Internal/WorkbookConstants.cs b/dotnet/src/ConnectedWorkbooks/Internal/WorkbookConstants.cs new file mode 100644 index 0000000..9a2c519 --- /dev/null +++ b/dotnet/src/ConnectedWorkbooks/Internal/WorkbookConstants.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System.Text.RegularExpressions; + +namespace Microsoft.ConnectedWorkbooks.Internal; + +/// +/// Central location for common workbook part paths, namespaces, and other constants. +/// +internal static class WorkbookConstants +{ + public const string ConnectionsXmlPath = "xl/connections.xml"; + public const string SharedStringsXmlPath = "xl/sharedStrings.xml"; + public const string DefaultSheetPath = "xl/worksheets/sheet1.xml"; + public const string DefaultTablePath = "xl/tables/table1.xml"; + public const string QueryTablesFolder = "xl/queryTables/"; + public const string QueryTablePath = "xl/queryTables/queryTable1.xml"; + public const string WorkbookXmlPath = "xl/workbook.xml"; + public const string WorkbookRelsPath = "xl/_rels/workbook.xml.rels"; + public const string PivotCachesFolder = "xl/pivotCache/"; + public const string Section1mPath = "Formulas/Section1.m"; + public const string DocPropsCoreXmlPath = "docProps/core.xml"; + public const string DocPropsAppXmlPath = "docProps/app.xml"; + public const string ContentTypesPath = "[Content_Types].xml"; + public const string RootRelsPath = "_rels/.rels"; + public const string DocMetadataPath = "docMetadata"; + public const string CustomXmlFolder = "customXml"; + public const string LabelInfoPath = "docMetadata/LabelInfo.xml"; + public const string TablesFolder = "xl/tables/"; + public const string DefaultQueryName = "Query1"; + + public const string ConnectedWorkbookNamespace = "http://schemas.microsoft.com/ConnectedWorkbook"; + public const string DataMashupNamespace = "http://schemas.microsoft.com/DataMashup"; + + public const int MaxQueryLength = 80; + public const int MaxCellCharacters = 32767; + + public static readonly Regex CustomXmlItemRegex = new(@"^customXml/item(\d+)\.xml$", RegexOptions.Compiled | RegexOptions.IgnoreCase); +} + diff --git a/dotnet/src/ConnectedWorkbooks/Internal/WorkbookEditor.cs b/dotnet/src/ConnectedWorkbooks/Internal/WorkbookEditor.cs new file mode 100644 index 0000000..43b5456 --- /dev/null +++ b/dotnet/src/ConnectedWorkbooks/Internal/WorkbookEditor.cs @@ -0,0 +1,404 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System.Globalization; +using System.Xml.Linq; +using Microsoft.ConnectedWorkbooks.Models; + +namespace Microsoft.ConnectedWorkbooks.Internal; + +/// +/// Coordinates the various OpenXML edits required to transform the template into a Connected Workbook. +/// +internal sealed class WorkbookEditor +{ + private readonly ExcelArchive _archive; + private readonly DocumentProperties? _documentProperties; + private readonly string _worksheetPath; + private readonly string _tablePath; + private readonly (int Row, int Column) _tableStart; + + /// + /// Initializes a new instance bound to the supplied archive and template metadata. + /// + /// Underlying workbook archive to mutate. + /// Optional document metadata to stamp. + /// Pre-resolved worksheet/table information. + public WorkbookEditor(ExcelArchive archive, DocumentProperties? documentProperties, TemplateMetadata templateMetadata) + { + _archive = archive; + _documentProperties = documentProperties; + _worksheetPath = templateMetadata.WorksheetPath; + _tablePath = templateMetadata.TablePath; + _tableStart = templateMetadata.TableStart; + } + + /// + /// Rewrites the DataMashup payload with the supplied query definition. + /// + /// M script that should populate the template query. + /// Friendly query name to embed. + public void UpdatePowerQueryDocument(string queryBody, string queryName) + { + var (path, base64) = PqUtilities.GetDataMashup(_archive); + var mashupDocument = PowerQueryGenerator.GenerateSingleQueryMashup(queryName, queryBody); + var nextBase64 = MashupDocumentParser.ReplaceSingleQuery(base64, queryName, mashupDocument); + PqUtilities.SetDataMashup(_archive, path, nextBase64); + } + + /// + /// Updates the workbook connection entry to point at the new Power Query and returns its ID. + /// + /// Friendly name shown in Excel's connection UI. + /// Whether Excel should refresh on open. + /// The connection ID used by the workbook. + public string UpdateConnections(string queryName, bool refreshOnOpen) + { + var xml = _archive.ReadText(WorkbookConstants.ConnectionsXmlPath); + var doc = XDocument.Parse(xml); + var ns = doc.Root?.Name.Namespace ?? XNamespace.None; + var connection = doc.Root?.Element(ns + "connection") ?? throw new InvalidOperationException("Connections XML does not contain a connection element."); + var dbPr = connection.Element(ns + "dbPr") ?? throw new InvalidOperationException("Connections XML is missing the dbPr element."); + + connection.SetAttributeValue("name", $"Query - {queryName}"); + connection.SetAttributeValue("description", $"Connection to the '{queryName}' query in the workbook."); + dbPr.SetAttributeValue("connection", $"Provider=Microsoft.Mashup.OleDb.1;Data Source=$Workbook$;Location=\"{queryName}\";"); + dbPr.SetAttributeValue("command", $"SELECT * FROM [{queryName.Replace("]", "]]", StringComparison.Ordinal)}]"); + dbPr.SetAttributeValue("refreshOnLoad", refreshOnOpen ? "1" : "0"); + + _archive.WriteText(WorkbookConstants.ConnectionsXmlPath, doc.ToString(SaveOptions.DisableFormatting)); + return connection.Attribute("id")?.Value ?? "1"; + } + + /// + /// Ensures the query name exists in sharedStrings.xml and returns its index. + /// + /// Name to persist. + /// The shared string index (1-based) that contains the query name. + public int UpdateSharedStrings(string queryName) + { + var xml = _archive.ReadText(WorkbookConstants.SharedStringsXmlPath); + var doc = XDocument.Parse(xml, LoadOptions.PreserveWhitespace); + var ns = doc.Root?.Name.Namespace ?? XNamespace.None; + var textElements = doc.Descendants(ns + "t").ToList(); + var sharedStringIndex = textElements.Count; + var existing = textElements.Select((element, index) => (element, index)).FirstOrDefault(tuple => tuple.element.Value == queryName); + if (existing.element is not null) + { + sharedStringIndex = existing.index + 1; + } + else + { + var si = new XElement(ns + "si", new XElement(ns + "t", queryName)); + doc.Root!.Add(si); + IncrementAttribute(doc.Root, "count"); + IncrementAttribute(doc.Root, "uniqueCount"); + } + + _archive.WriteText(WorkbookConstants.SharedStringsXmlPath, doc.ToString(SaveOptions.DisableFormatting)); + return sharedStringIndex; + } + + /// + /// Points the worksheet's single cell at the provided shared string index. + /// + /// Index returned by . + public void UpdateWorksheet(int sharedStringIndex) + { + var xml = _archive.ReadText(_worksheetPath); + var doc = XDocument.Parse(xml, LoadOptions.PreserveWhitespace); + var ns = doc.Root?.Name.Namespace ?? XNamespace.None; + var cellValue = doc.Descendants(ns + XmlNames.Elements.CellValue).FirstOrDefault(); + if (cellValue is null) + { + throw new InvalidOperationException("Worksheet XML did not contain a cell value node."); + } + + cellValue.Value = sharedStringIndex.ToString(CultureInfo.InvariantCulture); + _archive.WriteText(_worksheetPath, doc.ToString(SaveOptions.DisableFormatting)); + } + + /// + /// Updates the legacy query table XML so it references the new connection and refresh behavior. + /// + /// Connection ID from . + /// Whether Excel should refresh on open. + public void UpdateQueryTable(string connectionId, bool refreshOnOpen) + { + var xml = _archive.ReadText(WorkbookConstants.QueryTablePath); + var doc = XDocument.Parse(xml, LoadOptions.PreserveWhitespace); + var ns = doc.Root?.Name.Namespace ?? XNamespace.None; + doc.Root?.SetAttributeValue("connectionId", connectionId); + doc.Root?.SetAttributeValue("refreshOnLoad", refreshOnOpen ? "1" : "0"); + _archive.WriteText(WorkbookConstants.QueryTablePath, doc.ToString(SaveOptions.DisableFormatting)); + } + + /// + /// Writes the supplied tabular data into the worksheet/table/query table definitions. + /// + /// Normalized table data. + public void UpdateTableData(TableData tableData) + { + if (tableData.ColumnNames.Count == 0) + { + return; + } + + UpdateSheetData(tableData); + UpdateTableDefinition(tableData); + UpdateWorkbookDefinedName(tableData); + UpdateQueryTableColumns(tableData); + } + + /// + /// Stamps the workbook's core properties file with timestamps and optional metadata. + /// + public void UpdateDocumentProperties() + { + var now = FormatW3CDate(DateTime.UtcNow); + var xml = _archive.ReadText(WorkbookConstants.DocPropsCoreXmlPath); + var doc = XDocument.Parse(xml, LoadOptions.PreserveWhitespace); + var cp = (XNamespace)"http://schemas.openxmlformats.org/package/2006/metadata/core-properties"; + var dc = (XNamespace)"http://purl.org/dc/elements/1.1/"; + var dcterms = (XNamespace)"http://purl.org/dc/terms/"; + var xsi = (XNamespace)"http://www.w3.org/2001/XMLSchema-instance"; + + SetElement(doc, cp + "coreProperties", dcterms + "created", now, xsi); + SetElement(doc, cp + "coreProperties", dcterms + "modified", now, xsi); + if (_documentProperties is not null) + { + SetElement(doc, cp + "coreProperties", dc + "title", _documentProperties.Title); + SetElement(doc, cp + "coreProperties", dc + "subject", _documentProperties.Subject); + SetElement(doc, cp + "coreProperties", dc + "creator", _documentProperties.CreatedBy); + SetElement(doc, cp + "coreProperties", dc + "description", _documentProperties.Description); + SetElement(doc, cp + "coreProperties", cp + "keywords", _documentProperties.Keywords); + SetElement(doc, cp + "coreProperties", cp + "lastModifiedBy", _documentProperties.LastModifiedBy); + SetElement(doc, cp + "coreProperties", cp + "category", _documentProperties.Category); + SetElement(doc, cp + "coreProperties", cp + "revision", _documentProperties.Revision); + } + + _archive.WriteText(WorkbookConstants.DocPropsCoreXmlPath, doc.ToString(SaveOptions.DisableFormatting)); + } + + private static string FormatW3CDate(DateTime utcDateTime) + { + return utcDateTime.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'Z'", CultureInfo.InvariantCulture); + } + + private void UpdateSheetData(TableData tableData) + { + var xml = _archive.ReadText(_worksheetPath); + var doc = XDocument.Parse(xml, LoadOptions.PreserveWhitespace); + var ns = doc.Root?.Name.Namespace ?? XNamespace.None; + var x14ac = doc.Root?.GetNamespaceOfPrefix("x14ac") ?? XNamespace.None; + var sheetData = doc.Descendants(ns + XmlNames.Elements.SheetData).FirstOrDefault(); + if (sheetData is null) + { + throw new InvalidOperationException("Worksheet XML is missing sheetData."); + } + + sheetData.RemoveNodes(); + var (startRow, startColumn) = _tableStart; + var spans = $"{startColumn}:{startColumn + tableData.ColumnNames.Count - 1}"; + + var headerRow = new XElement(ns + XmlNames.Elements.Row, + new XAttribute(XmlNames.Attributes.Row, startRow), + new XAttribute(XmlNames.Attributes.Spans, spans), + x14ac == XNamespace.None ? null : new XAttribute(x14ac + "dyDescent", "0.3")); + + for (var columnIndex = 0; columnIndex < tableData.ColumnNames.Count; columnIndex++) + { + headerRow.Add(CreateCell(ns, startColumn + columnIndex, startRow, tableData.ColumnNames[columnIndex], isHeader: true)); + } + + sheetData.Add(headerRow); + + for (var rowIndex = 0; rowIndex < tableData.Rows.Count; rowIndex++) + { + var excelRow = startRow + rowIndex + 1; + var row = new XElement(ns + XmlNames.Elements.Row, + new XAttribute(XmlNames.Attributes.Row, excelRow), + new XAttribute(XmlNames.Attributes.Spans, spans), + x14ac == XNamespace.None ? null : new XAttribute(x14ac + "dyDescent", "0.3")); + + var rowValues = tableData.Rows[rowIndex]; + for (var columnIndex = 0; columnIndex < tableData.ColumnNames.Count; columnIndex++) + { + var value = columnIndex < rowValues.Count ? rowValues[columnIndex] : string.Empty; + row.Add(CreateCell(ns, startColumn + columnIndex, excelRow, value, isHeader: false)); + } + + sheetData.Add(row); + } + + var endReference = CellReferenceHelper.BuildReference(_tableStart, tableData.ColumnNames.Count, tableData.Rows.Count + 1); + doc.Descendants(ns + XmlNames.Elements.Dimension).FirstOrDefault()?.SetAttributeValue(XmlNames.Attributes.Reference, endReference); + doc.Descendants(ns + XmlNames.Elements.Selection).FirstOrDefault()?.SetAttributeValue(XmlNames.Attributes.SqRef, endReference); + + _archive.WriteText(_worksheetPath, doc.ToString(SaveOptions.DisableFormatting)); + } + + private void UpdateTableDefinition(TableData tableData) + { + var xml = _archive.ReadText(_tablePath); + var doc = XDocument.Parse(xml, LoadOptions.PreserveWhitespace); + var ns = doc.Root?.Name.Namespace ?? XNamespace.None; + var tableColumns = doc.Descendants(ns + XmlNames.Elements.TableColumns).FirstOrDefault(); + if (tableColumns is null) + { + throw new InvalidOperationException("Table definition is missing tableColumns."); + } + + tableColumns.RemoveNodes(); + tableColumns.SetAttributeValue(XmlNames.Attributes.Count, tableData.ColumnNames.Count); + for (var index = 0; index < tableData.ColumnNames.Count; index++) + { + var column = new XElement(ns + XmlNames.Elements.TableColumn); + column.SetAttributeValue(XmlNames.Attributes.Id, index + 1); + column.SetAttributeValue(XmlNames.Attributes.Name, tableData.ColumnNames[index]); + column.SetAttributeValue(XmlNames.Attributes.UniqueName, (index + 1).ToString(CultureInfo.InvariantCulture)); + column.SetAttributeValue(XmlNames.Attributes.QueryTableFieldId, index + 1); + tableColumns.Add(column); + } + + var reference = CellReferenceHelper.BuildReference(_tableStart, tableData.ColumnNames.Count, tableData.Rows.Count + 1); + doc.Root?.SetAttributeValue(XmlNames.Attributes.Reference, reference); + doc.Descendants(ns + XmlNames.Elements.AutoFilter).FirstOrDefault()?.SetAttributeValue(XmlNames.Attributes.Reference, reference); + + _archive.WriteText(_tablePath, doc.ToString(SaveOptions.DisableFormatting)); + } + + private void UpdateWorkbookDefinedName(TableData tableData) + { + var xml = _archive.ReadText(WorkbookConstants.WorkbookXmlPath); + var doc = XDocument.Parse(xml, LoadOptions.PreserveWhitespace); + var ns = doc.Root?.Name.Namespace ?? XNamespace.None; + var definedName = doc.Descendants(ns + XmlNames.Elements.DefinedName).FirstOrDefault(); + if (definedName is null) + { + _archive.WriteText(WorkbookConstants.WorkbookXmlPath, doc.ToString(SaveOptions.DisableFormatting)); + return; + } + + var range = CellReferenceHelper.BuildReference(_tableStart, tableData.ColumnNames.Count, tableData.Rows.Count + 1); + var sheetPrefix = ExtractDefinedNameSheetPrefix(definedName.Value); + definedName.Value = CellReferenceHelper.WithAbsolute(range, sheetPrefix); + _archive.WriteText(WorkbookConstants.WorkbookXmlPath, doc.ToString(SaveOptions.DisableFormatting)); + } + + private void UpdateQueryTableColumns(TableData tableData) + { + if (!_archive.EntryExists(WorkbookConstants.QueryTablePath)) + { + return; + } + + var xml = _archive.ReadText(WorkbookConstants.QueryTablePath); + var doc = XDocument.Parse(xml, LoadOptions.PreserveWhitespace); + var ns = doc.Root?.Name.Namespace ?? XNamespace.None; + var fields = doc.Descendants(ns + XmlNames.Elements.QueryTableFields).FirstOrDefault(); + if (fields is null) + { + throw new InvalidOperationException("Query table definition is missing queryTableFields."); + } + + fields.RemoveNodes(); + for (var index = 0; index < tableData.ColumnNames.Count; index++) + { + var field = new XElement(ns + XmlNames.Elements.QueryTableField); + field.SetAttributeValue(XmlNames.Attributes.Id, index + 1); + field.SetAttributeValue(XmlNames.Attributes.Name, tableData.ColumnNames[index]); + field.SetAttributeValue(XmlNames.Attributes.TableColumnId, index + 1); + fields.Add(field); + } + + fields.SetAttributeValue(XmlNames.Attributes.Count, tableData.ColumnNames.Count); + doc.Descendants(ns + XmlNames.Elements.QueryTableRefresh).FirstOrDefault()?.SetAttributeValue(XmlNames.Attributes.NextId, tableData.ColumnNames.Count + 1); + _archive.WriteText(WorkbookConstants.QueryTablePath, doc.ToString(SaveOptions.DisableFormatting)); + } + + private XElement CreateCell(XNamespace ns, int column, int row, string value, bool isHeader) + { + var reference = $"{CellReferenceHelper.ColumnNumberToName(column - 1)}{row}"; + var cell = new XElement(ns + XmlNames.Elements.Cell, + new XAttribute("r", reference)); + + cell.SetAttributeValue("t", isHeader ? "str" : DetermineValueType(value)); + if (value.StartsWith(" ", StringComparison.Ordinal) || value.EndsWith(" ", StringComparison.Ordinal)) + { + cell.SetAttributeValue(XNamespace.Xml + "space", "preserve"); + } + + var cellValue = new XElement(ns + XmlNames.Elements.CellValue, value); + cell.Add(cellValue); + return cell; + } + + private static string DetermineValueType(string value) + { + if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out _)) + { + return "n"; + } + + if (bool.TryParse(value, out _)) + { + return "b"; + } + + return "str"; + } + + private static void IncrementAttribute(XElement element, string attributeName) + { + if (element.Attribute(attributeName) is XAttribute attr && int.TryParse(attr.Value, out var parsed)) + { + attr.Value = (parsed + 1).ToString(CultureInfo.InvariantCulture); + } + } + + private static void SetElement(XDocument doc, XName parentName, XName elementName, string? value, XNamespace? xsi = null) + { + if (value is null) + { + return; + } + + var parent = doc.Descendants(parentName).FirstOrDefault(); + if (parent is null) + { + return; + } + + var element = parent.Element(elementName); + if (element is null) + { + element = new XElement(elementName); + parent.Add(element); + } + + if (xsi is not null && elementName.NamespaceName.Contains("dcterms", StringComparison.Ordinal)) + { + element.SetAttributeValue(xsi + "type", "dcterms:W3CDTF"); + } + + element.Value = value; + } + + private static string ExtractDefinedNameSheetPrefix(string? definedNameValue) + { + if (string.IsNullOrWhiteSpace(definedNameValue)) + { + return string.Empty; + } + + var separatorIndex = definedNameValue.IndexOf('!'); + if (separatorIndex < 0) + { + return string.Empty; + } + + return definedNameValue[..separatorIndex]; + } +} + diff --git a/dotnet/src/ConnectedWorkbooks/Internal/XmlNames.cs b/dotnet/src/ConnectedWorkbooks/Internal/XmlNames.cs new file mode 100644 index 0000000..e44fd2b --- /dev/null +++ b/dotnet/src/ConnectedWorkbooks/Internal/XmlNames.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace Microsoft.ConnectedWorkbooks.Internal; + +/// +/// Centralizes frequently used OpenXML element/attribute names. +/// +internal static class XmlNames +{ + /// + /// Element names used throughout the workbook package. + /// + internal static class Elements + { + public const string SharedStringTable = "sst"; + public const string SharedStringItem = "si"; + public const string Text = "t"; + public const string CellValue = "v"; + public const string DatabaseProperties = "dbPr"; + public const string QueryTable = "queryTable"; + public const string CacheSource = "cacheSource"; + public const string Table = "table"; + public const string TableColumns = "tableColumns"; + public const string TableColumn = "tableColumn"; + public const string AutoFilter = "autoFilter"; + public const string SheetData = "sheetData"; + public const string Row = "row"; + public const string Cell = "c"; + public const string DefinedName = "definedName"; + public const string QueryTableFields = "queryTableFields"; + public const string QueryTableField = "queryTableField"; + public const string QueryTableRefresh = "queryTableRefresh"; + public const string Relationships = "Relationships"; + public const string Relationship = "Relationship"; + public const string Item = "Item"; + public const string ItemPath = "ItemPath"; + public const string Entry = "Entry"; + public const string Items = "Items"; + public const string Worksheet = "worksheet"; + public const string Dimension = "dimension"; + public const string Selection = "selection"; + } + + /// + /// Attribute names used throughout the workbook package. + /// + internal static class Attributes + { + public const string Count = "count"; + public const string UniqueCount = "uniqueCount"; + public const string RefreshOnLoad = "refreshOnLoad"; + public const string ConnectionId = "connectionId"; + public const string Name = "name"; + public const string Description = "description"; + public const string Connection = "connection"; + public const string Command = "command"; + public const string Id = "id"; + public const string RelId = "r:id"; + public const string Target = "Target"; + public const string PartName = "PartName"; + public const string ContentType = "ContentType"; + public const string Reference = "ref"; + public const string SqRef = "sqref"; + public const string TableColumnId = "tableColumnId"; + public const string UniqueName = "uniqueName"; + public const string QueryTableFieldId = "queryTableFieldId"; + public const string NextId = "nextId"; + public const string Row = "r"; + public const string Spans = "spans"; + public const string X14acDyDescent = "x14ac:dyDescent"; + public const string Type = "Type"; + public const string Value = "Value"; + public const string ResultType = "ResultType"; + } +} + diff --git a/dotnet/src/ConnectedWorkbooks/InternalsVisibleTo.cs b/dotnet/src/ConnectedWorkbooks/InternalsVisibleTo.cs new file mode 100644 index 0000000..f99f6f9 --- /dev/null +++ b/dotnet/src/ConnectedWorkbooks/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ConnectedWorkbooks.Tests")] diff --git a/dotnet/src/ConnectedWorkbooks/Models/DocumentProperties.cs b/dotnet/src/ConnectedWorkbooks/Models/DocumentProperties.cs new file mode 100644 index 0000000..1449347 --- /dev/null +++ b/dotnet/src/ConnectedWorkbooks/Models/DocumentProperties.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace Microsoft.ConnectedWorkbooks.Models; + +/// +/// Optional document metadata used to stamp core properties in the generated workbook. +/// +public sealed record DocumentProperties +{ + /// + /// Value written to dc:title. + /// + public string? Title { get; init; } + + /// + /// Value written to dc:subject. + /// + public string? Subject { get; init; } + + /// + /// Optional keywords associated with the document. + /// + public string? Keywords { get; init; } + + /// + /// Author recorded in dc:creator. + /// + public string? CreatedBy { get; init; } + + /// + /// Long-form description of the workbook. + /// + public string? Description { get; init; } + + /// + /// Value written to cp:lastModifiedBy. + /// + public string? LastModifiedBy { get; init; } + + /// + /// Optional category classification. + /// + public string? Category { get; init; } + + /// + /// Revision string assigned to cp:revision. + /// + public string? Revision { get; init; } +} + diff --git a/dotnet/src/ConnectedWorkbooks/Models/FileConfiguration.cs b/dotnet/src/ConnectedWorkbooks/Models/FileConfiguration.cs new file mode 100644 index 0000000..0a9a405 --- /dev/null +++ b/dotnet/src/ConnectedWorkbooks/Models/FileConfiguration.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace Microsoft.ConnectedWorkbooks.Models; + +/// +/// Optional knobs used when generating a workbook from the .NET implementation. +/// +public sealed record FileConfiguration +{ + /// + /// When provided, the workbook will be generated using the supplied template bytes instead of the built-in one. + /// + public byte[]? TemplateBytes { get; init; } + + /// + /// Document metadata that should be applied to docProps/core.xml. + /// + public DocumentProperties? DocumentProperties { get; init; } + + /// + /// Fine grained instructions that help the generator locate the right sheet/table inside a custom template. + /// + public TemplateSettings? TemplateSettings { get; init; } +} + diff --git a/dotnet/src/ConnectedWorkbooks/Models/Grid.cs b/dotnet/src/ConnectedWorkbooks/Models/Grid.cs new file mode 100644 index 0000000..67ce52b --- /dev/null +++ b/dotnet/src/ConnectedWorkbooks/Models/Grid.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace Microsoft.ConnectedWorkbooks.Models; + +/// +/// Simple 2D grid abstraction used for data ingestion. +/// +/// Rows and columns that represent the dataset. +/// Optional configuration that influences how the grid is interpreted. +public sealed record Grid(IReadOnlyList> Data, GridConfig? Config = null); + diff --git a/dotnet/src/ConnectedWorkbooks/Models/GridConfig.cs b/dotnet/src/ConnectedWorkbooks/Models/GridConfig.cs new file mode 100644 index 0000000..6ace473 --- /dev/null +++ b/dotnet/src/ConnectedWorkbooks/Models/GridConfig.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace Microsoft.ConnectedWorkbooks.Models; + +/// +/// Controls how incoming grid data should be interpreted when converted into an Excel table. +/// +public sealed record GridConfig +{ + /// + /// Treat the first row of as the header row. + /// + public bool PromoteHeaders { get; init; } = true; + + /// + /// Automatically fix duplicate/blank headers by appending numeric suffixes. + /// + public bool AdjustColumnNames { get; init; } = true; +} + diff --git a/dotnet/src/ConnectedWorkbooks/Models/QueryInfo.cs b/dotnet/src/ConnectedWorkbooks/Models/QueryInfo.cs new file mode 100644 index 0000000..c60e27a --- /dev/null +++ b/dotnet/src/ConnectedWorkbooks/Models/QueryInfo.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace Microsoft.ConnectedWorkbooks.Models; + +/// +/// Describes a single Power Query definition that should be injected into the generated workbook. +/// +public sealed record QueryInfo +{ + /// + /// Creates a new query description. + /// + /// The Power Query (M) text that defines the query. + /// Optional friendly name; defaults to Query1 if omitted. + /// Whether Excel should refresh the query automatically when the workbook opens. + public QueryInfo(string queryMashup, string? queryName = null, bool refreshOnOpen = true) + { + QueryMashup = string.IsNullOrWhiteSpace(queryMashup) + ? throw new ArgumentException("Query mashup cannot be null or empty.", nameof(queryMashup)) + : queryMashup; + + QueryName = string.IsNullOrWhiteSpace(queryName) ? null : queryName; + RefreshOnOpen = refreshOnOpen; + } + + /// + /// Gets the Power Query (M) script. + /// + public string QueryMashup { get; } + + /// + /// Gets the friendly query name (or null to fall back to the default). + /// + public string? QueryName { get; } + + /// + /// Gets a value indicating whether the query should refresh when the workbook opens. + /// + public bool RefreshOnOpen { get; } +} + diff --git a/dotnet/src/ConnectedWorkbooks/Models/TableData.cs b/dotnet/src/ConnectedWorkbooks/Models/TableData.cs new file mode 100644 index 0000000..b0f0563 --- /dev/null +++ b/dotnet/src/ConnectedWorkbooks/Models/TableData.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace Microsoft.ConnectedWorkbooks.Models; + +/// +/// Normalized representation of tabular data that can be written into the workbook. +/// +/// Column headers that appear in the table. +/// Table rows aligned with . +public sealed record TableData(IReadOnlyList ColumnNames, IReadOnlyList> Rows) +{ +} + diff --git a/dotnet/src/ConnectedWorkbooks/Models/TemplateSettings.cs b/dotnet/src/ConnectedWorkbooks/Models/TemplateSettings.cs new file mode 100644 index 0000000..d2fb26b --- /dev/null +++ b/dotnet/src/ConnectedWorkbooks/Models/TemplateSettings.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace Microsoft.ConnectedWorkbooks.Models; + +/// +/// Optional overrides used when supplying a custom workbook template. +/// +public sealed record TemplateSettings +{ + /// + /// Optional table name override inside the custom template. + /// + public string? TableName { get; init; } + + /// + /// Optional worksheet name override inside the custom template. + /// + public string? SheetName { get; init; } +} + diff --git a/dotnet/src/ConnectedWorkbooks/Templates/SIMPLE_BLANK_TABLE_TEMPLATE.xlsx b/dotnet/src/ConnectedWorkbooks/Templates/SIMPLE_BLANK_TABLE_TEMPLATE.xlsx new file mode 100644 index 0000000..ec392d7 Binary files /dev/null and b/dotnet/src/ConnectedWorkbooks/Templates/SIMPLE_BLANK_TABLE_TEMPLATE.xlsx differ diff --git a/dotnet/src/ConnectedWorkbooks/Templates/SIMPLE_QUERY_WORKBOOK_TEMPLATE.xlsx b/dotnet/src/ConnectedWorkbooks/Templates/SIMPLE_QUERY_WORKBOOK_TEMPLATE.xlsx new file mode 100644 index 0000000..d9e7aa8 Binary files /dev/null and b/dotnet/src/ConnectedWorkbooks/Templates/SIMPLE_QUERY_WORKBOOK_TEMPLATE.xlsx differ diff --git a/dotnet/src/ConnectedWorkbooks/WorkbookManager.cs b/dotnet/src/ConnectedWorkbooks/WorkbookManager.cs new file mode 100644 index 0000000..fba143e --- /dev/null +++ b/dotnet/src/ConnectedWorkbooks/WorkbookManager.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using Microsoft.ConnectedWorkbooks.Internal; +using Microsoft.ConnectedWorkbooks.Models; + +namespace Microsoft.ConnectedWorkbooks; + +/// +/// Entry point for generating Connected Workbooks from .NET. +/// +public sealed class WorkbookManager +{ + /// + /// Generates a workbook that contains a single Power Query backed by optional initial grid data. + /// + /// Information about the query to embed. + /// Optional grid whose data should seed the query table. + /// Optional template and document configuration overrides. + /// The generated workbook bytes. + public byte[] GenerateSingleQueryWorkbook( + QueryInfo query, + Grid? initialDataGrid = null, + FileConfiguration? fileConfiguration = null) + { + ArgumentNullException.ThrowIfNull(query); + + var templateBytes = fileConfiguration?.TemplateBytes + ?? EmbeddedTemplateLoader.LoadSimpleQueryTemplate(); + var tableData = initialDataGrid is null ? null : GridParser.Parse(initialDataGrid); + var effectiveQueryName = QueryNameValidator.Resolve(query.QueryName); + + using var archive = ExcelArchive.Load(templateBytes); + var templateMetadata = TemplateMetadataResolver.Resolve(archive, fileConfiguration?.TemplateSettings); + var editor = new WorkbookEditor(archive, fileConfiguration?.DocumentProperties, templateMetadata); + editor.UpdatePowerQueryDocument(query.QueryMashup, effectiveQueryName); + + var connectionId = editor.UpdateConnections(effectiveQueryName, query.RefreshOnOpen); + var sharedStringIndex = editor.UpdateSharedStrings(effectiveQueryName); + editor.UpdateWorksheet(sharedStringIndex); + editor.UpdateQueryTable(connectionId, query.RefreshOnOpen); + if (tableData is not null) + { + editor.UpdateTableData(tableData); + } + editor.UpdateDocumentProperties(); + + return archive.ToArray(); + } + + /// + /// Generates a workbook that contains only a table populated from the supplied grid. + /// + /// The source grid data. + /// Optional template/document overrides. + /// The generated workbook bytes. + public byte[] GenerateTableWorkbookFromGrid( + Grid grid, + FileConfiguration? fileConfiguration = null) + { + ArgumentNullException.ThrowIfNull(grid); + + var templateBytes = fileConfiguration?.TemplateBytes + ?? EmbeddedTemplateLoader.LoadBlankTableTemplate(); + var tableData = GridParser.Parse(grid); + + using var archive = ExcelArchive.Load(templateBytes); + var templateMetadata = TemplateMetadataResolver.Resolve(archive, fileConfiguration?.TemplateSettings); + var editor = new WorkbookEditor(archive, fileConfiguration?.DocumentProperties, templateMetadata); + editor.UpdateTableData(tableData); + editor.UpdateDocumentProperties(); + + return archive.ToArray(); + } +} diff --git a/dotnet/tests/ConnectedWorkbooks.Tests/ConnectedWorkbooks.Tests.csproj b/dotnet/tests/ConnectedWorkbooks.Tests/ConnectedWorkbooks.Tests.csproj new file mode 100644 index 0000000..13f022a --- /dev/null +++ b/dotnet/tests/ConnectedWorkbooks.Tests/ConnectedWorkbooks.Tests.csproj @@ -0,0 +1,28 @@ +ο»Ώ + + + net8.0 + latest + enable + enable + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/dotnet/tests/ConnectedWorkbooks.Tests/MSTestSettings.cs b/dotnet/tests/ConnectedWorkbooks.Tests/MSTestSettings.cs new file mode 100644 index 0000000..b8a247d --- /dev/null +++ b/dotnet/tests/ConnectedWorkbooks.Tests/MSTestSettings.cs @@ -0,0 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)] + diff --git a/dotnet/tests/ConnectedWorkbooks.Tests/PqUtilitiesTests.cs b/dotnet/tests/ConnectedWorkbooks.Tests/PqUtilitiesTests.cs new file mode 100644 index 0000000..6bd3579 --- /dev/null +++ b/dotnet/tests/ConnectedWorkbooks.Tests/PqUtilitiesTests.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System.Text; +using Microsoft.ConnectedWorkbooks.Internal; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ConnectedWorkbooks.Tests; + +[TestClass] +public sealed class PqUtilitiesTests +{ + [TestMethod] + public void GetDataMashupHandlesUtf16LittleEndianBom() + { + AssertDataMashupRoundtrip(Encoding.Unicode); + } + + [TestMethod] + public void GetDataMashupHandlesUtf16BigEndianBom() + { + AssertDataMashupRoundtrip(Encoding.BigEndianUnicode); + } + + [TestMethod] + public void GetDataMashupHandlesUtf8Bom() + { + AssertDataMashupRoundtrip(new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)); + } + + private static void AssertDataMashupRoundtrip(Encoding encoding) + { + var template = EmbeddedTemplateLoader.LoadSimpleQueryTemplate(); + using var archive = ExcelArchive.Load(template); + var base64 = Convert.ToBase64String(Guid.NewGuid().ToByteArray()); + WriteDataMashup(archive, base64, encoding); + + var (_, decodedBase64) = PqUtilities.GetDataMashup(archive); + Assert.AreEqual(base64, decodedBase64, $"DataMashup decoding failed for encoding {encoding.WebName}."); + } + + private static void WriteDataMashup(ExcelArchive archive, string base64, Encoding encoding) + { + var path = LocateDataMashupEntry(archive); + var xml = $"{base64}"; + var preamble = encoding.GetPreamble(); + var payload = encoding.GetBytes(xml); + var buffer = new byte[preamble.Length + payload.Length]; + preamble.CopyTo(buffer, 0); + payload.CopyTo(buffer, preamble.Length); + archive.WriteBytes(path, buffer); + } + + private static string LocateDataMashupEntry(ExcelArchive archive) + { + foreach (var entryPath in archive.EnumerateEntries(WorkbookConstants.CustomXmlFolder)) + { + if (!WorkbookConstants.CustomXmlItemRegex.IsMatch(entryPath)) + { + continue; + } + + var xml = archive.ReadText(entryPath); + var doc = System.Xml.Linq.XDocument.Parse(xml); + if (string.Equals(doc.Root?.Name.LocalName, "DataMashup", StringComparison.Ordinal)) + { + return entryPath; + } + } + + throw new AssertFailedException("DataMashup entry was not found in the template."); + } +} diff --git a/dotnet/tests/ConnectedWorkbooks.Tests/TestResults/90a856a6-0001-416c-bed0-a68b7b8eb703/coverage.cobertura.xml b/dotnet/tests/ConnectedWorkbooks.Tests/TestResults/90a856a6-0001-416c-bed0-a68b7b8eb703/coverage.cobertura.xml new file mode 100644 index 0000000..6e0d849 --- /dev/null +++ b/dotnet/tests/ConnectedWorkbooks.Tests/TestResults/90a856a6-0001-416c-bed0-a68b7b8eb703/coverage.cobertura.xml @@ -0,0 +1,3020 @@ + + + + E:\work\connected-workbooks\dotnet\src\ConnectedWorkbooks\ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/tests/ConnectedWorkbooks.Tests/WorkbookManagerTests.cs b/dotnet/tests/ConnectedWorkbooks.Tests/WorkbookManagerTests.cs new file mode 100644 index 0000000..64c7e94 --- /dev/null +++ b/dotnet/tests/ConnectedWorkbooks.Tests/WorkbookManagerTests.cs @@ -0,0 +1,276 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Xml.Linq; +using Microsoft.ConnectedWorkbooks; +using Microsoft.ConnectedWorkbooks.Internal; +using Microsoft.ConnectedWorkbooks.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ConnectedWorkbooks.Tests; + +[TestClass] +public sealed class WorkbookManagerTests +{ + private readonly WorkbookManager _manager = new(); + private const string CustomSheetName = "DataSheet"; + private const string CustomSheetPath = "xl/worksheets/customSheet.xml"; + private const string CustomSheetRelsPath = "xl/worksheets/_rels/customSheet.xml.rels"; + private const string CustomTableName = "SalesTable"; + private const string CustomTablePath = "xl/tables/customTable.xml"; + private const string CustomTableRange = "C3:D5"; + + [TestMethod] + public void GeneratesWorkbookWithMashupAndTable() + { + var queryBody = @"let + Source = Kusto.Contents(""https://help.kusto.windows.net"", ""Samples"", ""StormEvents"") +in + Source"; + var query = new QueryInfo(queryBody, "DataAgentQuery", refreshOnOpen: true); + var grid = new Grid(new List> + { + new List { "City", "Count" }, + new List { "Seattle", 42 }, + new List { "London", 12 } + }, new GridConfig { PromoteHeaders = true, AdjustColumnNames = true }); + + var bytes = _manager.GenerateSingleQueryWorkbook(query, grid); + Assert.IsTrue(bytes.Length > 0, "The generated workbook should not be empty."); + + using var archiveStream = new MemoryStream(bytes); + using var archive = new ZipArchive(archiveStream, ZipArchiveMode.Read); + + var connections = ReadEntry(archive, "xl/connections.xml"); + StringAssert.Contains(connections, "Query - DataAgentQuery"); + + var sharedStrings = ReadEntry(archive, "xl/sharedStrings.xml"); + StringAssert.Contains(sharedStrings, "DataAgentQuery"); + + var sheet = ReadEntry(archive, "xl/worksheets/sheet1.xml"); + StringAssert.Contains(sheet, "Seattle"); + StringAssert.Contains(sheet, "London"); + + var table = ReadEntry(archive, "xl/tables/table1.xml"); + StringAssert.Contains(table, "City"); + StringAssert.Contains(table, "Count"); + + var mashupXml = ReadEntry(archive, "customXml/item1.xml"); + StringAssert.Contains(mashupXml, "DataMashup"); + var root = XDocument.Parse(mashupXml).Root ?? throw new AssertFailedException("DataMashup XML root was missing."); + var sectionContent = ExtractSection1m(root.Value.Trim()); + StringAssert.Contains(sectionContent, "shared #\"DataAgentQuery\""); + StringAssert.Contains(sectionContent, "Kusto.Contents(\"https://help.kusto.windows.net\""); + } + + [TestMethod] + public void GeneratesTableWorkbookFromGrid() + { + var grid = new Grid(new List> + { + new List { "Product", "Quantity", "Price" }, + new List { "Apples", 5, 1.25 }, + new List { "Bananas", 8, 0.99 } + }, new GridConfig { PromoteHeaders = true }); + + var bytes = _manager.GenerateTableWorkbookFromGrid(grid); + Assert.IsTrue(bytes.Length > 0, "The generated workbook should not be empty."); + + using var archiveStream = new MemoryStream(bytes); + using var archive = new ZipArchive(archiveStream, ZipArchiveMode.Read); + + var tableXml = ReadEntry(archive, "xl/tables/table1.xml"); + StringAssert.Contains(tableXml, "Product"); + StringAssert.Contains(tableXml, "Quantity"); + StringAssert.Contains(tableXml, "Price"); + + var sheetXml = ReadEntry(archive, "xl/worksheets/sheet1.xml"); + StringAssert.Contains(sheetXml, "Apples"); + StringAssert.Contains(sheetXml, "Bananas"); + } + + [TestMethod] + public void RejectsInvalidQueryName() + { + var query = new QueryInfo("let Source = 1 in Source", "Invalid.Name", refreshOnOpen: false); + Assert.ThrowsException(() => _manager.GenerateSingleQueryWorkbook(query)); + } + + [TestMethod] + public void DefaultsQueryNameWhenMissing() + { + var query = new QueryInfo("let Source = 1 in Source"); + var bytes = _manager.GenerateSingleQueryWorkbook(query); + using var archiveStream = new MemoryStream(bytes); + using var archive = new ZipArchive(archiveStream, ZipArchiveMode.Read); + var connections = ReadEntry(archive, "xl/connections.xml"); + StringAssert.Contains(connections, "Query - Query1"); + } + + [TestMethod] + public void RequiresTemplateSettingsWhenDefaultsMissing() + { + var template = CreateCustomTemplate(); + var grid = new Grid(new List> + { + new List { "Col1", "Col2" }, + new List { "A", "B" } + }, new GridConfig { PromoteHeaders = true }); + + var config = new FileConfiguration { TemplateBytes = template }; + + Assert.ThrowsException(() => _manager.GenerateTableWorkbookFromGrid(grid, config)); + } + + [TestMethod] + public void GeneratesTableWorkbookWithTemplateSettings() + { + var template = CreateCustomTemplate(); + var grid = new Grid(new List> + { + new List { "Product", "Qty" }, + new List { "Apples", 5 }, + new List { "Bananas", 3 } + }, new GridConfig { PromoteHeaders = true }); + + var config = new FileConfiguration + { + TemplateBytes = template, + TemplateSettings = new TemplateSettings + { + SheetName = CustomSheetName, + TableName = CustomTableName + } + }; + + var bytes = _manager.GenerateTableWorkbookFromGrid(grid, config); + + using var archiveStream = new MemoryStream(bytes); + using var archive = new ZipArchive(archiveStream, ZipArchiveMode.Read); + + var sheetXml = ReadEntry(archive, CustomSheetPath); + var sheetDoc = XDocument.Parse(sheetXml); + var sheetNs = sheetDoc.Root?.Name.Namespace ?? XNamespace.None; + var firstCell = sheetDoc.Descendants(sheetNs + "c").First(); + Assert.AreEqual("C3", firstCell.Attribute("r")?.Value, "Table data was not written at the expected starting cell."); + + var tableXml = ReadEntry(archive, CustomTablePath); + StringAssert.Contains(tableXml, $"name=\"{CustomTableName}\""); + StringAssert.Contains(tableXml, CustomTableRange, "Table reference was not updated."); + } + + private static string ReadEntry(ZipArchive archive, string path) + { + var entry = archive.GetEntry(path) ?? throw new AssertFailedException($"Entry '{path}' was not found in the workbook."); + using var stream = entry.Open(); + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + return reader.ReadToEnd(); + } + + private static byte[] CreateCustomTemplate() + { + var template = EmbeddedTemplateLoader.LoadBlankTableTemplate(); + using var stream = new MemoryStream(); + stream.Write(template, 0, template.Length); + + using (var zip = new ZipArchive(stream, ZipArchiveMode.Update, leaveOpen: true)) + { + RenameEntry(zip, "xl/worksheets/sheet1.xml", CustomSheetPath); + RenameEntry(zip, "xl/worksheets/_rels/sheet1.xml.rels", CustomSheetRelsPath); + RenameEntry(zip, "xl/tables/table1.xml", CustomTablePath); + + MutateXmlEntry(zip, "xl/workbook.xml", doc => + { + var ns = doc.Root?.Name.Namespace ?? XNamespace.None; + var sheet = doc.Descendants(ns + "sheet").First(); + sheet.SetAttributeValue("name", CustomSheetName); + }); + + MutateXmlEntry(zip, "xl/_rels/workbook.xml.rels", doc => + { + var ns = doc.Root?.Name.Namespace ?? XNamespace.None; + var relationship = doc.Descendants(ns + "Relationship") + .First(node => node.Attribute("Target")?.Value?.EndsWith("worksheets/sheet1.xml", StringComparison.OrdinalIgnoreCase) == true); + relationship.SetAttributeValue("Target", "worksheets/customSheet.xml"); + }); + + MutateXmlEntry(zip, CustomSheetRelsPath, doc => + { + var ns = doc.Root?.Name.Namespace ?? XNamespace.None; + foreach (var relationship in doc.Descendants(ns + "Relationship")) + { + var target = relationship.Attribute("Target")?.Value; + if (target != null && target.EndsWith("../tables/table1.xml", StringComparison.OrdinalIgnoreCase)) + { + relationship.SetAttributeValue("Target", "../tables/customTable.xml"); + } + } + }); + + MutateXmlEntry(zip, CustomTablePath, doc => + { + doc.Root?.SetAttributeValue("name", CustomTableName); + doc.Root?.SetAttributeValue("displayName", CustomTableName); + doc.Root?.SetAttributeValue("ref", CustomTableRange); + }); + } + + return stream.ToArray(); + } + + private static void RenameEntry(ZipArchive zip, string originalName, string newName) + { + var entry = zip.GetEntry(originalName) ?? throw new AssertFailedException($"Entry '{originalName}' not found in the workbook template."); + using var buffer = new MemoryStream(); + using (var source = entry.Open()) + { + source.CopyTo(buffer); + } + + entry.Delete(); + var newEntry = zip.CreateEntry(newName); + using var target = newEntry.Open(); + buffer.Position = 0; + buffer.CopyTo(target); + } + + private static void MutateXmlEntry(ZipArchive zip, string path, Action mutate) + { + var entry = zip.GetEntry(path) ?? throw new AssertFailedException($"Entry '{path}' not found in the workbook template."); + string xml; + using (var reader = new StreamReader(entry.Open(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) + { + xml = reader.ReadToEnd(); + } + + var doc = XDocument.Parse(xml, LoadOptions.PreserveWhitespace); + mutate(doc); + + entry.Delete(); + var newEntry = zip.CreateEntry(path); + using var writer = new StreamWriter(newEntry.Open(), new UTF8Encoding(false)); + writer.Write(doc.ToString(SaveOptions.DisableFormatting)); + } + + private static string ExtractSection1m(string dataMashupBase64) + { + var bytes = Convert.FromBase64String(dataMashupBase64); + using var memory = new MemoryStream(bytes); + using var binaryReader = new BinaryReader(memory); + binaryReader.ReadBytes(4); // version header + var packageSize = binaryReader.ReadInt32(); + var packageBytes = binaryReader.ReadBytes(packageSize); + + using var packageStream = new MemoryStream(packageBytes); + using var packageZip = new ZipArchive(packageStream, ZipArchiveMode.Read); + var entry = packageZip.GetEntry("Formulas/Section1.m") ?? throw new AssertFailedException("Section1.m was not found in the mashup package."); + using var entryStream = entry.Open(); + using var reader = new StreamReader(entryStream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + return reader.ReadToEnd(); + } +} + diff --git a/.eslintrc.js b/typescript/.eslintrc.js similarity index 100% rename from .eslintrc.js rename to typescript/.eslintrc.js diff --git a/.prettierrc b/typescript/.prettierrc similarity index 100% rename from .prettierrc rename to typescript/.prettierrc diff --git a/typescript/README.md b/typescript/README.md new file mode 100644 index 0000000..c5c97b7 --- /dev/null +++ b/typescript/README.md @@ -0,0 +1,208 @@ +
+ +# Connected Workbooks (TypeScript) + +[![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=flat&logo=typescript&logoColor=white)](https://www.typescriptlang.org/) +[![npm version](https://img.shields.io/npm/v/@microsoft/connected-workbooks)](https://www.npmjs.com/package/@microsoft/connected-workbooks) +[![Build Status](https://img.shields.io/github/actions/workflow/status/microsoft/connected-workbooks/azure-pipelines.yml?branch=main)](https://github.com/microsoft/connected-workbooks/actions) + +
+ +> Need the high-level overview, feature tour, or repo structure? See the root [`README.md`](../README.md). This file covers the TypeScript package specifically. + +--- + +## Installation + +```bash +npm install @microsoft/connected-workbooks +``` + +The library targets evergreen browsers. No native modules, build steps, or Office add-ins are required. + +--- + +## Usage Examples + +### HTML Table Export + +```ts +import { workbookManager } from '@microsoft/connected-workbooks'; + +const table = document.querySelector('table') as HTMLTableElement; +const blob = await workbookManager.generateTableWorkbookFromHtml(table); +await workbookManager.openInExcelWeb(blob, 'QuickExport.xlsx', true); +``` + +### Grid Export With Smart Headers + +```ts +const grid = { + config: { + promoteHeaders: true, + adjustColumnNames: true + }, + data: [ + ['Product', 'Revenue', 'InStock', 'Category'], + ['Surface Laptop', 1299.99, true, 'Hardware'], + ['Office 365', 99.99, true, 'Software'] + ] +}; + +const blob = await workbookManager.generateTableWorkbookFromGrid(grid); +await workbookManager.openInExcelWeb(blob, 'SalesReport.xlsx', true); +``` + +### Inject Data Into Templates + +1. Design an `.xlsx` template that includes a table (for example `QuarterlyData`). +2. Upload or fetch the template in the browser. +3. Supply the file plus metadata: + +```ts +const templateResponse = await fetch('/assets/templates/sales-dashboard.xlsx'); +const templateFile = await templateResponse.blob(); + +const blob = await workbookManager.generateTableWorkbookFromGrid( + quarterlyData, + undefined, + { + templateFile, + TempleteSettings: { + sheetName: 'Dashboard', + tableName: 'QuarterlyData' + } + } +); + +await workbookManager.openInExcelWeb(blob, 'ExecutiveDashboard.xlsx', true); +``` + +### Power Query Workbooks + +```ts +const blob = await workbookManager.generateSingleQueryWorkbook({ + queryMashup: `let Source = Json.Document(Web.Contents('https://contoso/api/orders')) in Source`, + refreshOnOpen: true +}); + +await workbookManager.openInExcelWeb(blob, 'Orders.xlsx', true); +``` + +### Document Properties & Downloads + +```ts +const blob = await workbookManager.generateTableWorkbookFromHtml(table, { + docProps: { + createdBy: 'Contoso Portal', + description: 'Q4 pipeline export', + title: 'Executive Dashboard' + } +}); + +workbookManager.downloadWorkbook(blob, 'Pipeline.xlsx'); +``` + +--- + +## API Surface + +### `generateSingleQueryWorkbook()` + +```ts +async function generateSingleQueryWorkbook( + query: QueryInfo, + grid?: Grid, + fileConfigs?: FileConfigs +): Promise +``` + +- `query`: Power Query definition (M script, refresh flag, query name). +- `grid`: Optional seed data. +- `fileConfigs`: Template, metadata, or host options. + +### `generateTableWorkbookFromHtml()` + +```ts +async function generateTableWorkbookFromHtml( + htmlTable: HTMLTableElement, + fileConfigs?: FileConfigs +): Promise +``` + +### `generateTableWorkbookFromGrid()` + +```ts +async function generateTableWorkbookFromGrid( + grid: Grid, + fileConfigs?: FileConfigs +): Promise +``` + +### `openInExcelWeb()` and `getExcelForWebWorkbookUrl()` + +Launch Excel for the Web immediately or just capture the URL for custom navigation flows. + +### `downloadWorkbook()` + +Trigger a regular browser download of the generated blob. + +--- + +## Type Definitions + +```ts +interface QueryInfo { + queryMashup: string; + refreshOnOpen: boolean; + queryName?: string; // default: "Query1" +} + +interface Grid { + data: (string | number | boolean | null)[][]; + config?: { + promoteHeaders?: boolean; + adjustColumnNames?: boolean; + }; +} + +interface FileConfigs { + templateFile?: File | Blob | Buffer; + docProps?: DocProps; + hostName?: string; + TempleteSettings?: { + tableName?: string; + sheetName?: string; + }; +} + +interface DocProps { + title?: string; + subject?: string; + keywords?: string; + createdBy?: string; + description?: string; + lastModifiedBy?: string; + category?: string; + revision?: string; +} +``` + +--- + +## Development + +```bash +cd typescript +npm install +npm run build +npm test +``` + +Use `npm run validate:implementations` to compare the TypeScript and .NET output when making cross-language changes. + +--- + +## Contributing + +Follow the guidance in the root [`README.md`](../README.md#contributing). Pull requests should include unit tests (`npm test`) and adhere to the repo ESLint/Prettier settings before submission. diff --git a/babel.config.js b/typescript/babel.config.js similarity index 100% rename from babel.config.js rename to typescript/babel.config.js diff --git a/jest.config.jsdom.js b/typescript/jest.config.jsdom.js similarity index 100% rename from jest.config.jsdom.js rename to typescript/jest.config.jsdom.js diff --git a/jest.config.node.js b/typescript/jest.config.node.js similarity index 100% rename from jest.config.node.js rename to typescript/jest.config.node.js diff --git a/package-lock.json b/typescript/package-lock.json similarity index 99% rename from package-lock.json rename to typescript/package-lock.json index 210b67b..2a3fb07 100644 --- a/package-lock.json +++ b/typescript/package-lock.json @@ -87,6 +87,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.6.tgz", "integrity": "sha512-qAHSfAdVyFmIvl0VHELib8xar7ONuSHrE2hLnsaWkYNTI68dmi1x8GYDhJjMI/e7XWal9QBlZkwbOnkcw7Z8gQ==", "dev": true, + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.24.6", @@ -3016,7 +3017,6 @@ "integrity": "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 18" } @@ -3046,8 +3046,7 @@ "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@octokit/core/node_modules/@octokit/types": { "version": "13.10.0", @@ -3055,7 +3054,6 @@ "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/openapi-types": "^24.2.0" } @@ -3097,7 +3095,6 @@ "integrity": "sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/request": "^8.4.1", "@octokit/types": "^13.0.0", @@ -3112,8 +3109,7 @@ "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@octokit/graphql/node_modules/@octokit/types": { "version": "13.10.0", @@ -3121,7 +3117,6 @@ "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/openapi-types": "^24.2.0" } @@ -4044,6 +4039,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.33.0.tgz", "integrity": "sha512-ZohdsbXadjGBSK0/r+d87X0SBmKzOq4/S5nzK6SBgJspFo9/CUDJ7hjayuze+JK7CZQLDMroqytp7pOcFKTxZA==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "4.33.0", "@typescript-eslint/types": "4.33.0", @@ -4415,6 +4411,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4492,6 +4489,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5071,6 +5069,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001587", "electron-to-chromium": "^1.4.668", @@ -6153,6 +6152,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz", "integrity": "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==", "dev": true, + "peer": true, "dependencies": { "@babel/code-frame": "7.12.11", "@eslint/eslintrc": "^0.4.3", @@ -12268,6 +12268,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.2.tgz", "integrity": "sha512-lnJzDfJ66zkMy58OL5/NY5zp70S7Nz6KqcKkXYzn2tMVrNxvbqaBpg7H3qHaLxCJ5lNMsGuM8+ohS7cZrthdLQ==", "dev": true, + "peer": true, "bin": { "prettier": "bin-prettier.js" }, @@ -14464,6 +14465,7 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-9.1.1.tgz", "integrity": "sha512-hPlt7ZACERQGf03M253ytLY3dHbGNGrAq9qIHWUY9XHYl1z7wYngSr3OQ5xmui8o2AaxsONxIzjafLUiWBo1Fg==", "dev": true, + "peer": true, "dependencies": { "arg": "^4.1.0", "create-require": "^1.1.0", @@ -14895,6 +14897,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15215,6 +15218,7 @@ "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.5", "@webassemblyjs/ast": "^1.12.1", @@ -15329,6 +15333,7 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -15407,6 +15412,7 @@ "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, diff --git a/package.json b/typescript/package.json similarity index 95% rename from package.json rename to typescript/package.json index 2062add..f7ada26 100644 --- a/package.json +++ b/typescript/package.json @@ -19,7 +19,8 @@ "test:clean": "npm run clean && npm run test", "test:node": "tsc && jest --config jest.config.node.js", "test:jsdom": "tsc && jest --config jest.config.jsdom.js", - "test": "npm run test:jsdom && npm run test:node" + "test": "npm run test:jsdom && npm run test:node", + "validate:implementations": "node scripts/validateImplementations.js" }, "repository": { "type": "git", diff --git a/typescript/scripts/generateWeatherSample.js b/typescript/scripts/generateWeatherSample.js new file mode 100644 index 0000000..50a55ba --- /dev/null +++ b/typescript/scripts/generateWeatherSample.js @@ -0,0 +1,44 @@ +const path = require("path"); +const fs = require("fs/promises"); +const { workbookManager } = require("../dist"); + +const mashup = `let + Source = #table( + {"City","TempC"}, + { + {"Seattle", 18}, + {"London", 15}, + {"Sydney", 22} + } + ) +in + Source`; + +const query = { + queryMashup: mashup, + queryName: "WeatherSample", + refreshOnOpen: false, +}; + +const grid = { + data: [ + ["City", "TempC"], + ["Seattle", 0], + ["London", 0], + ["Sydney", 0], + ], + config: { promoteHeaders: true }, +}; + +async function main() { + const blob = await workbookManager.generateSingleQueryWorkbook(query, grid); + const buffer = Buffer.from(await blob.arrayBuffer()); + const outputPath = path.resolve(__dirname, "..", "..", "WeatherSample.ts.xlsx"); + await fs.writeFile(outputPath, buffer); + console.log(`TypeScript workbook generated: ${outputPath}`); +} + +main().catch((error) => { + console.error("Failed to generate TypeScript workbook", error); + process.exitCode = 1; +}); diff --git a/typescript/scripts/inspectMashup.js b/typescript/scripts/inspectMashup.js new file mode 100644 index 0000000..2bd9379 --- /dev/null +++ b/typescript/scripts/inspectMashup.js @@ -0,0 +1,104 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const path = require("path"); +const { extractWorkbookDetails } = require("./workbookDetails"); + +function usage() { + console.error("Usage:"); + console.error(" node inspectMashup.js --pretty "); + console.error(" node inspectMashup.js "); +} + +function printPretty(details) { + const sectionPreview = details.sectionContent.split("\n").slice(0, 5).join("\n"); + const metadataPreview = details.metadataXml.slice(0, 400); + console.log(`\n${details.workbookPath}`); + console.log(` Query name : ${details.queryName}`); + console.log(` Metadata : ${details.metadataBytesLength ?? details.metadataXml.length} bytes`); + console.log(" Section1.m preview:\n" + sectionPreview.split("\n").map((line) => ` ${line}`).join("\n")); + console.log(" Metadata preview:\n" + metadataPreview.split("\n").map((line) => ` ${line}`).join("\n")); +} + +function compare(detailsA, detailsB) { + const checks = [ + ["Query name", detailsA.queryName, detailsB.queryName], + ["Connection name", detailsA.connection.name, detailsB.connection.name], + ["Connection description", detailsA.connection.description, detailsB.connection.description], + ["Connection location", detailsA.connection.location, detailsB.connection.location], + ["Connection command", detailsA.connection.command, detailsB.connection.command], + ["Refresh flag", detailsA.connection.refreshOnLoad, detailsB.connection.refreshOnLoad], + ]; + + const mismatches = []; + for (const [label, left, right] of checks) { + if (left !== right) { + mismatches.push(`${label} mismatch:\n ${detailsA.workbookPath}: ${left}\n ${detailsB.workbookPath}: ${right}`); + } + } + + const section1Filter = (paths) => paths.filter((entry) => entry.includes("Section1/")); + const leftPaths = section1Filter(detailsA.metadataItemPaths); + const rightPaths = section1Filter(detailsB.metadataItemPaths); + if (JSON.stringify(leftPaths) !== JSON.stringify(rightPaths)) { + mismatches.push("Metadata ItemPath entries differ"); + } + + if (!detailsA.sharedStrings.includes(detailsA.queryName)) { + mismatches.push(`${path.basename(detailsA.workbookPath)} sharedStrings missing query name`); + } + + if (!detailsB.sharedStrings.includes(detailsB.queryName)) { + mismatches.push(`${path.basename(detailsB.workbookPath)} sharedStrings missing query name`); + } + + return mismatches; +} + +async function prettyMode(workbook) { + const details = await extractWorkbookDetails(path.resolve(workbook)); + printPretty(details); +} + +async function comparisonMode(left, right) { + const leftDetails = await extractWorkbookDetails(path.resolve(left)); + const rightDetails = await extractWorkbookDetails(path.resolve(right)); + const mismatches = compare(leftDetails, rightDetails); + if (mismatches.length === 0) { + console.log("\nβœ… Workbooks match for inspected fields."); + return; + } + + console.error("\n❌ Workbooks differ:"); + mismatches.forEach((item) => console.error(` - ${item}`)); + process.exit(1); +} + +async function main() { + const args = process.argv.slice(2); + if (args.length === 0) { + usage(); + process.exit(1); + } + + if (args[0] === "--pretty") { + if (args.length !== 2) { + usage(); + process.exit(1); + } + + await prettyMode(args[1]); + return; + } + + if (args.length === 2) { + await comparisonMode(args[0], args[1]); + return; + } + + usage(); + process.exit(1); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/typescript/scripts/validateImplementations.js b/typescript/scripts/validateImplementations.js new file mode 100644 index 0000000..acb0583 --- /dev/null +++ b/typescript/scripts/validateImplementations.js @@ -0,0 +1,41 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const path = require("path"); +const { execSync } = require("child_process"); + +const repoRoot = path.resolve(__dirname, "..", ".."); +const tsRoot = path.resolve(__dirname, ".."); + +function run(command, cwd) { + execSync(command, { cwd, stdio: "inherit" }); +} + +async function ensureSamples() { + run("dotnet run --project dotnet/sample/ConnectedWorkbooks.Sample.csproj", repoRoot); + run("npm run build", tsRoot); + run("node scripts/generateWeatherSample.js", tsRoot); +} + +async function main() { + const args = process.argv.slice(2); + let dotnetWorkbook; + let tsWorkbook; + + if (args.length === 2) { + [dotnetWorkbook, tsWorkbook] = args.map((arg) => path.resolve(arg)); + } else if (args.length === 0) { + await ensureSamples(); + dotnetWorkbook = path.resolve(repoRoot, "dotnet", "WeatherSample.xlsx"); + tsWorkbook = path.resolve(repoRoot, "WeatherSample.ts.xlsx"); + } else { + console.error("Usage: node scripts/validateImplementations.js [dotnetWorkbook tsWorkbook]"); + process.exit(1); + } + + const inspectScript = path.resolve(tsRoot, "scripts", "inspectMashup.js"); + run(`node "${inspectScript}" "${dotnetWorkbook}" "${tsWorkbook}"`, repoRoot); +} + +main().catch((error) => { + console.error(error.message || error); + process.exit(1); +}); diff --git a/typescript/scripts/workbookDetails.js b/typescript/scripts/workbookDetails.js new file mode 100644 index 0000000..fea203b --- /dev/null +++ b/typescript/scripts/workbookDetails.js @@ -0,0 +1,159 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const fs = require("fs/promises"); +const path = require("path"); +const JSZip = require("jszip"); +const { DOMParser } = require("@xmldom/xmldom"); + +const DATA_MASHUP_REGEX = /]*>([\s\S]+?)<\/DataMashup>/i; + +function decodeXmlBuffer(buffer) { + if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) { + return buffer.toString("utf16le").replace(/^\ufeff/, ""); + } + + return buffer.toString("utf8").replace(/^\ufeff/, ""); +} + +async function loadWorkbookZip(workbookPath) { + const absolutePath = path.resolve(workbookPath); + const buffer = await fs.readFile(absolutePath); + return JSZip.loadAsync(buffer); +} + +function extractQueryName(sectionContent) { + const normalized = sectionContent.replace(/\r/g, ""); + const match = normalized.match(/shared\s+(?:#")?([^"\n]+)"?\s*=/i); + return match ? match[1].trim() : ""; +} + +async function readMashup(zip) { + const entry = zip.file("customXml/item1.xml"); + if (!entry) { + throw new Error("customXml/item1.xml not found"); + } + + const xmlBuffer = await entry.async("nodebuffer"); + const mashupXml = decodeXmlBuffer(xmlBuffer); + const match = mashupXml.match(DATA_MASHUP_REGEX); + if (!match) { + throw new Error("DataMashup payload missing"); + } + + const payloadBuffer = Buffer.from(match[1].trim(), "base64"); + let offset = 0; + const readInt32 = () => { + const value = payloadBuffer.readInt32LE(offset); + offset += 4; + return value; + }; + + offset += 4; // version + const packageSize = readInt32(); + const packageBytes = payloadBuffer.subarray(offset, offset + packageSize); + offset += packageSize; + const permissionsSize = readInt32(); + offset += permissionsSize; + const metadataSize = readInt32(); + const metadataBytes = payloadBuffer.subarray(offset, offset + metadataSize); + + const packageZip = await JSZip.loadAsync(packageBytes); + const sectionEntry = packageZip.file("Formulas/Section1.m"); + if (!sectionEntry) { + throw new Error("Formulas/Section1.m missing"); + } + + const sectionContent = (await sectionEntry.async("text")).trim(); + const queryName = extractQueryName(sectionContent); + + let metadataOffset = 4; // metadata version + const metadataXmlLength = metadataBytes.readInt32LE(metadataOffset); + metadataOffset += 4; + const metadataXml = metadataBytes.subarray(metadataOffset, metadataOffset + metadataXmlLength).toString("utf8"); + + return { + queryName, + sectionContent, + metadataBytesLength: metadataBytes.length, + metadataXml, + }; +} + +function parseSharedStrings(xml) { + if (!xml) { + return []; + } + + const doc = new DOMParser().parseFromString(xml, "text/xml"); + const nodes = doc.getElementsByTagName("t"); + const values = []; + for (let i = 0; i < nodes.length; i++) { + values.push((nodes[i].textContent || "").trim()); + } + + return values; +} + +function parseMetadataPaths(metadataXml) { + const doc = new DOMParser().parseFromString(metadataXml, "text/xml"); + const nodes = doc.getElementsByTagName("ItemPath"); + const paths = []; + for (let i = 0; i < nodes.length; i++) { + paths.push(nodes[i].textContent || ""); + } + + return paths; +} + +async function extractWorkbookDetails(workbookPath) { + const zip = await loadWorkbookZip(workbookPath); + const mashup = await readMashup(zip); + + const connectionsEntry = zip.file("xl/connections.xml"); + if (!connectionsEntry) { + throw new Error("xl/connections.xml not found"); + } + + const connectionsXml = await connectionsEntry.async("text"); + const connectionDoc = new DOMParser().parseFromString(connectionsXml, "text/xml"); + const connectionNode = connectionDoc.getElementsByTagName("connection")[0]; + const dbPrNode = connectionDoc.getElementsByTagName("dbPr")[0]; + + const connection = { + id: connectionNode?.getAttribute("id") || "", + name: connectionNode?.getAttribute("name") || "", + description: connectionNode?.getAttribute("description") || "", + refreshOnLoad: dbPrNode?.getAttribute("refreshOnLoad") || "", + location: dbPrNode?.getAttribute("connection") || "", + command: dbPrNode?.getAttribute("command") || "", + }; + + const sharedStringsEntry = zip.file("xl/sharedStrings.xml"); + const sharedStringsXml = sharedStringsEntry ? await sharedStringsEntry.async("text") : ""; + + return { + workbookPath: path.resolve(workbookPath), + queryName: mashup.queryName, + metadataXml: mashup.metadataXml, + metadataBytesLength: mashup.metadataBytesLength, + sectionContent: mashup.sectionContent, + metadataItemPaths: parseMetadataPaths(mashup.metadataXml), + connection, + sharedStrings: parseSharedStrings(sharedStringsXml), + }; +} + +async function extractMashupInfo(workbookPath) { + const details = await extractWorkbookDetails(workbookPath); + return { + workbookPath: details.workbookPath, + queryName: details.queryName, + sectionContent: details.sectionContent, + metadataBytesLength: details.metadataBytesLength, + metadataXml: details.metadataXml, + }; +} + +module.exports = { + extractMashupInfo, + extractWorkbookDetails, +}; diff --git a/src/generators.ts b/typescript/src/generators.ts similarity index 100% rename from src/generators.ts rename to typescript/src/generators.ts diff --git a/src/index.ts b/typescript/src/index.ts similarity index 100% rename from src/index.ts rename to typescript/src/index.ts diff --git a/src/types.ts b/typescript/src/types.ts similarity index 100% rename from src/types.ts rename to typescript/src/types.ts diff --git a/src/utils/arrayUtils.ts b/typescript/src/utils/arrayUtils.ts similarity index 100% rename from src/utils/arrayUtils.ts rename to typescript/src/utils/arrayUtils.ts diff --git a/src/utils/constants.ts b/typescript/src/utils/constants.ts similarity index 100% rename from src/utils/constants.ts rename to typescript/src/utils/constants.ts diff --git a/src/utils/documentUtils.ts b/typescript/src/utils/documentUtils.ts similarity index 100% rename from src/utils/documentUtils.ts rename to typescript/src/utils/documentUtils.ts diff --git a/src/utils/gridUtils.ts b/typescript/src/utils/gridUtils.ts similarity index 100% rename from src/utils/gridUtils.ts rename to typescript/src/utils/gridUtils.ts diff --git a/src/utils/htmlUtils.ts b/typescript/src/utils/htmlUtils.ts similarity index 100% rename from src/utils/htmlUtils.ts rename to typescript/src/utils/htmlUtils.ts diff --git a/src/utils/index.ts b/typescript/src/utils/index.ts similarity index 100% rename from src/utils/index.ts rename to typescript/src/utils/index.ts diff --git a/src/utils/mashupDocumentParser.ts b/typescript/src/utils/mashupDocumentParser.ts similarity index 100% rename from src/utils/mashupDocumentParser.ts rename to typescript/src/utils/mashupDocumentParser.ts diff --git a/src/utils/pqUtils.ts b/typescript/src/utils/pqUtils.ts similarity index 100% rename from src/utils/pqUtils.ts rename to typescript/src/utils/pqUtils.ts diff --git a/src/utils/tableUtils.ts b/typescript/src/utils/tableUtils.ts similarity index 100% rename from src/utils/tableUtils.ts rename to typescript/src/utils/tableUtils.ts diff --git a/src/utils/xmlInnerPartsUtils.ts b/typescript/src/utils/xmlInnerPartsUtils.ts similarity index 100% rename from src/utils/xmlInnerPartsUtils.ts rename to typescript/src/utils/xmlInnerPartsUtils.ts diff --git a/src/utils/xmlPartsUtils.ts b/typescript/src/utils/xmlPartsUtils.ts similarity index 100% rename from src/utils/xmlPartsUtils.ts rename to typescript/src/utils/xmlPartsUtils.ts diff --git a/src/workbookManager.ts b/typescript/src/workbookManager.ts similarity index 100% rename from src/workbookManager.ts rename to typescript/src/workbookManager.ts diff --git a/src/workbookTemplate.ts b/typescript/src/workbookTemplate.ts similarity index 100% rename from src/workbookTemplate.ts rename to typescript/src/workbookTemplate.ts diff --git a/tests/arrayUtils.test.ts b/typescript/tests/arrayUtils.test.ts similarity index 100% rename from tests/arrayUtils.test.ts rename to typescript/tests/arrayUtils.test.ts diff --git a/tests/documentUtils.test.ts b/typescript/tests/documentUtils.test.ts similarity index 100% rename from tests/documentUtils.test.ts rename to typescript/tests/documentUtils.test.ts diff --git a/tests/gridUtils.test.ts b/typescript/tests/gridUtils.test.ts similarity index 100% rename from tests/gridUtils.test.ts rename to typescript/tests/gridUtils.test.ts diff --git a/tests/htmlUtils.test.ts b/typescript/tests/htmlUtils.test.ts similarity index 100% rename from tests/htmlUtils.test.ts rename to typescript/tests/htmlUtils.test.ts diff --git a/tests/mashupDocumentParser.test.ts b/typescript/tests/mashupDocumentParser.test.ts similarity index 100% rename from tests/mashupDocumentParser.test.ts rename to typescript/tests/mashupDocumentParser.test.ts diff --git a/tests/mocks/PqMock.ts b/typescript/tests/mocks/PqMock.ts similarity index 100% rename from tests/mocks/PqMock.ts rename to typescript/tests/mocks/PqMock.ts diff --git a/tests/mocks/index.ts b/typescript/tests/mocks/index.ts similarity index 100% rename from tests/mocks/index.ts rename to typescript/tests/mocks/index.ts diff --git a/tests/mocks/section1mSimpleQueryMock.ts b/typescript/tests/mocks/section1mSimpleQueryMock.ts similarity index 100% rename from tests/mocks/section1mSimpleQueryMock.ts rename to typescript/tests/mocks/section1mSimpleQueryMock.ts diff --git a/tests/mocks/workbookMocks.ts b/typescript/tests/mocks/workbookMocks.ts similarity index 100% rename from tests/mocks/workbookMocks.ts rename to typescript/tests/mocks/workbookMocks.ts diff --git a/tests/mocks/xmlMocks.ts b/typescript/tests/mocks/xmlMocks.ts similarity index 100% rename from tests/mocks/xmlMocks.ts rename to typescript/tests/mocks/xmlMocks.ts diff --git a/tests/tableUtils.test.ts b/typescript/tests/tableUtils.test.ts similarity index 100% rename from tests/tableUtils.test.ts rename to typescript/tests/tableUtils.test.ts diff --git a/tests/workbookQueryTemplate.test.ts b/typescript/tests/workbookQueryTemplate.test.ts similarity index 100% rename from tests/workbookQueryTemplate.test.ts rename to typescript/tests/workbookQueryTemplate.test.ts diff --git a/tests/workbookTableTemplate.test.ts b/typescript/tests/workbookTableTemplate.test.ts similarity index 100% rename from tests/workbookTableTemplate.test.ts rename to typescript/tests/workbookTableTemplate.test.ts diff --git a/tests/xmlInnerPartsUtils.test.ts b/typescript/tests/xmlInnerPartsUtils.test.ts similarity index 100% rename from tests/xmlInnerPartsUtils.test.ts rename to typescript/tests/xmlInnerPartsUtils.test.ts diff --git a/tsconfig.json b/typescript/tsconfig.json similarity index 100% rename from tsconfig.json rename to typescript/tsconfig.json diff --git a/tsconfig.test.json b/typescript/tsconfig.test.json similarity index 100% rename from tsconfig.test.json rename to typescript/tsconfig.test.json diff --git a/webpack.config.js b/typescript/webpack.config.js similarity index 100% rename from webpack.config.js rename to typescript/webpack.config.js