diff --git a/.editorconfig b/.editorconfig index b94e6b0c..af3d1b07 100644 --- a/.editorconfig +++ b/.editorconfig @@ -90,7 +90,7 @@ dotnet_diagnostic.CA1707.severity = suggestion # =========================== # Core layer: treat warnings as errors (all warnings fixed) -[src/ExcelMcp.Core/**/*.cs] +[src/PptMcp.Core/**/*.cs] dotnet_analyzer_diagnostic.category-CodeQuality.severity = error dotnet_analyzer_diagnostic.category-Design.severity = warning # API design changes require careful planning dotnet_analyzer_diagnostic.category-Performance.severity = error @@ -116,7 +116,7 @@ dotnet_diagnostic.IDE0072.severity = suggestion # Populate switch dotnet_diagnostic.IDE0018.severity = suggestion # Variable declaration inlining # CLI layer: Some ToString warnings remain - treat most as errors -[src/ExcelMcp.CLI/**/*.cs] +[src/PptMcp.CLI/**/*.cs] dotnet_analyzer_diagnostic.category-Globalization.severity = error dotnet_analyzer_diagnostic.category-Performance.severity = error dotnet_diagnostic.CA1305.severity = warning # ToString culture warnings in display layer (acceptable for CLI) @@ -124,14 +124,14 @@ dotnet_diagnostic.CA1311.severity = warning # ToLower warnings in display layer dotnet_diagnostic.CA1304.severity = warning # Culture warnings in display layer (acceptable for CLI) # MCP Server layer: Additional warnings to fix in follow-up -[src/ExcelMcp.McpServer/**/*.cs] +[src/PptMcp.McpServer/**/*.cs] dotnet_diagnostic.CA1861.severity = warning # Static readonly arrays - low risk, fix in follow-up dotnet_diagnostic.CA1866.severity = warning # String.StartsWith(char) - performance optimization dotnet_diagnostic.CA1310.severity = warning # StartsWith StringComparison - low risk in prompts dotnet_diagnostic.CA1869.severity = warning # JsonSerializerOptions caching - fix in follow-up # ComInterop layer: Windows-only, platform warnings expected -[src/ExcelMcp.ComInterop/**/*.cs] +[src/PptMcp.ComInterop/**/*.cs] dotnet_diagnostic.CA1416.severity = suggestion # Platform-specific APIs expected (Windows-only library) diff --git a/.gitattributes b/.gitattributes index 1cf8946b..7ed3e354 100644 --- a/.gitattributes +++ b/.gitattributes @@ -27,8 +27,8 @@ # Binary files *.dll binary *.exe binary -*.xlsx binary -*.xlsm binary +*.pptx binary +*.pptm binary *.png binary *.jpg binary *.jpeg binary diff --git a/.github/ISSUE_TEMPLATE/breaking-changes-issue.md b/.github/ISSUE_TEMPLATE/breaking-changes-issue.md index ea453412..8169272f 100644 --- a/.github/ISSUE_TEMPLATE/breaking-changes-issue.md +++ b/.github/ISSUE_TEMPLATE/breaking-changes-issue.md @@ -12,12 +12,12 @@ Implement breaking changes from `MCP-BREAKING-CHANGES-PROPOSAL.md` before the 1. ## Objectives -Since ExcelMcp MCP Server hasn't been released yet, we can make breaking changes without affecting users. This is a **golden opportunity** to improve the API before 1.0. +Since PptMcp MCP Server hasn't been released yet, we can make breaking changes without affecting users. This is a **golden opportunity** to improve the API before 1.0. ### Key Changes 1. **Better Terminology**: `batchId` → `sessionId` (clearer intent) -2. **Consistent Naming**: `excelPath` → `filePath`, `sheetName` → `worksheetName` +2. **Consistent Naming**: `presentationPath` → `filePath`, `slideIndex` parameter changes 3. **Standardized Errors**: Error codes and structured error responses 4. **Cleaner Code**: Remove redundant validation attributes 5. **Richer Responses**: Add metadata to all tool outputs @@ -34,12 +34,12 @@ Since ExcelMcp MCP Server hasn't been released yet, we can make breaking changes - [ ] Rename `BatchSessionTool.cs` → `SessionTool.cs` - [ ] Rename tools: - - `begin_excel_batch` → `begin_excel_session` - - `commit_excel_batch` → `end_excel_session` - - `list_excel_batches` → `list_excel_sessions` + - `begin_ppt_batch` → `begin_ppt_session` + - `commit_ppt_batch` → `end_ppt_session` + - `list_ppt_batches` → `list_ppt_sessions` - [ ] Update all `batchId` parameters to `sessionId` in: - - All 9 tool files in `src/ExcelMcp.McpServer/Tools/` - - `ExcelToolsBase.cs` + - All 9 tool files in `src/PptMcp.McpServer/Tools/` + - `PptToolsBase.cs` - All prompt files (4 files) - [ ] Update documentation: - `BATCH-SESSION-GUIDE.md` → `SESSION-GUIDE.md` @@ -49,30 +49,30 @@ Since ExcelMcp MCP Server hasn't been released yet, we can make breaking changes - [ ] Update tests (all files referencing batchId) - [ ] Update Program.cs cleanup handler -#### 1.2 excelPath → filePath +#### 1.2 presentationPath → filePath **Affected files**: 16 C# files - [ ] Update all tool files: - - `ExcelPowerQueryTool.cs` - - `ExcelWorksheetTool.cs` - - `ExcelParameterTool.cs` - - `ExcelCellTool.cs` - - `ExcelVbaTool.cs` - - `ExcelConnectionTool.cs` - - `ExcelDataModelTool.cs` - - `ExcelFileTool.cs` - - `HyperlinkTool.cs` - - `TableTool.cs` + - `PptSlideTool.cs` + - `PptShapeTool.cs` + - `PptTextTool.cs` + - `PptChartTool.cs` + - `PptVbaTool.cs` + - `PptAnimationTool.cs` + - `PptTransitionTool.cs` + - `PptFileTool.cs` + - `PptNotesTool.cs` + - `PptMediaTool.cs` - [ ] Update all Core command interfaces - [ ] Update all Core command implementations - [ ] Update all tests - [ ] Update all prompt content and documentation -#### 1.3 sheetName → worksheetName +#### 1.3 slideIndex parameter changes **Affected files**: ~5 files -- [ ] `ExcelWorksheetTool.cs` -- [ ] Worksheet Core commands +- [ ] `PptSlideTool.cs` +- [ ] Slide Core commands - [ ] Related tests - [ ] Prompt content - [ ] Documentation @@ -80,16 +80,16 @@ Since ExcelMcp MCP Server hasn't been released yet, we can make breaking changes ### Phase 2: Error Response Standardization (1-2 days) #### 2.1 Define Error Codes -- [ ] Create `src/ExcelMcp.Core/Models/ErrorCodes.cs` +- [ ] Create `src/PptMcp.Core/Models/ErrorCodes.cs` - [ ] Define standard error codes: ```csharp FILE_NOT_FOUND - QUERY_NOT_FOUND - WORKSHEET_NOT_FOUND - INVALID_M_CODE - PRIVACY_LEVEL_REQUIRED + SLIDE_NOT_FOUND + SHAPE_NOT_FOUND + INVALID_PARAMETER + ANIMATION_ERROR VBA_TRUST_REQUIRED - EXCEL_BUSY + POWERPOINT_BUSY SESSION_NOT_FOUND SESSION_FILE_MISMATCH ``` @@ -100,11 +100,11 @@ Since ExcelMcp MCP Server hasn't been released yet, we can make breaking changes { "success": false, "error": { - "code": "QUERY_NOT_FOUND", - "message": "Power Query 'SalesData' not found", + "code": "SLIDE_NOT_FOUND", + "message": "Slide 'Intro' not found", "details": { - "queryName": "SalesData", - "availableQueries": ["Data1", "Data2"] + "slideName": "Intro", + "availableSlides": ["Slide1", "Slide2"] } } } @@ -183,8 +183,8 @@ Since ExcelMcp MCP Server hasn't been released yet, we can make breaking changes ## Files Affected **C# Files**: ~30 files -- 9 tool files in `src/ExcelMcp.McpServer/Tools/` -- 4 prompt files in `src/ExcelMcp.McpServer/Prompts/` +- 9 tool files in `src/PptMcp.McpServer/Tools/` +- 4 prompt files in `src/PptMcp.McpServer/Prompts/` - 1 Program.cs - ~10 Core command files - ~10 test files @@ -201,8 +201,8 @@ Since ExcelMcp MCP Server hasn't been released yet, we can make breaking changes ## Success Criteria - [ ] All `batchId` references changed to `sessionId` -- [ ] All `excelPath` references changed to `filePath` -- [ ] All `sheetName` references changed to `worksheetName` +- [ ] All `presentationPath` references changed to `filePath` +- [ ] All `slideIndex` references updated consistently - [ ] Error response format standardized with error codes - [ ] Validation attributes cleaned up - [ ] Rich metadata added to all responses @@ -233,7 +233,7 @@ git checkout -b feature/breaking-changes-pre-1.0 git commit -m "Phase 1.1: Rename batchId to sessionId" # Test # Implement Phase 1.2 -git commit -m "Phase 1.2: Rename excelPath to filePath" +git commit -m "Phase 1.2: Rename presentationPath to filePath" # Continue... ``` diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index aea48bdf..1788baab 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,6 +1,6 @@ --- name: Bug Report -about: Create a report to help us improve ExcelMcp +about: Create a report to help us improve PptMcp title: '[BUG] ' labels: 'bug' assignees: '' @@ -12,19 +12,19 @@ A clear and concise description of what the bug is. ## Component Which component is this bug related to? -- [ ] **MCP Server** (Model Context Protocol server for AI assistants - `mcp-excel`) -- [ ] **CLI** (Command-line interface - `ExcelMcp.exe`) +- [ ] **MCP Server** (Model Context Protocol server for AI assistants - `mcp-ppt`) +- [ ] **CLI** (Command-line interface - `PptMcp.exe`) - [ ] **Core Library** (Shared functionality) - [ ] **Not sure** ## Command/Usage **For CLI:** ``` -ExcelMcp +PptMcp ``` **For MCP Server:** -- Tool name: [e.g., powerquery, worksheet, etc.] +- Tool name: [e.g., slide, shape, text, chart, etc.] - Action: [e.g., list, view, import, etc.] - Parameters used: [describe what was passed] @@ -42,21 +42,21 @@ If applicable, paste the full error message: ## Environment - **Windows Version**: [e.g. Windows 11, Windows 10] -- **Excel Version**: [e.g. Excel 365, Excel 2019] -- **ExcelMcp Version**: [e.g. v1.0.0] +- **PowerPoint Version**: [e.g. PowerPoint 365, PowerPoint 2019] +- **PptMcp Version**: [e.g. v1.0.0] - **.NET Version**: [Run `dotnet --version`] - **Installation Method**: [NuGet tool / Binary download / Source build] -- **File Format**: [e.g. .xlsx, .xlsm] +- **File Format**: [e.g. .pptx, .pptm] - **VBA Trust Enabled**: [Yes/No - if VBA-related issue] - **AI Assistant** (if using MCP Server): [e.g., GitHub Copilot, Claude Desktop, ChatGPT, etc.] ## Sample File -If possible, attach a sample Excel file that reproduces the issue (remove sensitive data). +If possible, attach a sample PowerPoint file that reproduces the issue (remove sensitive data). ## VBA-Related Issues (if applicable) -- [ ] VBA trust is properly configured (`ExcelMcp check-vba-trust`) -- [ ] Using .xlsm file format for VBA commands -- [ ] VBA module exists in the workbook +- [ ] VBA trust is properly configured (`PptMcp check-vba-trust`) +- [ ] Using .pptm file format for VBA commands +- [ ] VBA module exists in the presentation - [ ] Macro security settings allow programmatic access ## Steps to Reproduce @@ -68,7 +68,7 @@ If possible, attach a sample Excel file that reproduces the issue (remove sensit ## Additional Context Add any other context about the problem here. -## Excel Process Cleanup -- [ ] Excel processes clean up properly after the command -- [ ] Excel processes remain running (this is part of the bug) +## PowerPoint Process Cleanup +- [ ] PowerPoint processes clean up properly after the command +- [ ] PowerPoint processes remain running (this is part of the bug) - [ ] Not applicable/unsure \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 6862302f..ed17d39b 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,6 +1,6 @@ --- name: Feature Request -about: Suggest an idea for ExcelMcp +about: Suggest an idea for PptMcp title: '[FEATURE] ' labels: 'enhancement' assignees: '' @@ -25,11 +25,11 @@ A clear and concise description of what you want to happen. **For CLI:** ```bash -ExcelMcp new-command +PptMcp new-command ``` **For MCP Server:** -- Tool: [e.g., powerquery, worksheet, vba] +- Tool: [e.g., slide, shape, text, vba] - Action: [e.g., new-action] - Parameters: [describe expected parameters] @@ -38,32 +38,30 @@ A clear and concise description of any alternative solutions or features you've ## Use Case Describe the specific use case this feature would address: -- [ ] Power Query management - [ ] VBA script automation -- [ ] Worksheet operations +- [ ] Slide operations - [ ] Data analysis - [ ] Coding agent automation -- [ ] Macro-enabled workbook (.xlsm) operations +- [ ] Macro-enabled presentation (.pptm) operations - [ ] Other: [please specify] ## Target Users Who would benefit from this feature? - [ ] **AI Assistants** (GitHub Copilot, Claude, ChatGPT via MCP Server) - [ ] **Direct CLI Users** (Command-line automation) -- [ ] **CI/CD Pipelines** (Automated Excel development workflows) -- [ ] **Excel Developers** (Power Query, VBA development) +- [ ] **CI/CD Pipelines** (Automated PowerPoint development workflows) +- [ ] **PowerPoint Developers** (VBA development) - [ ] **Data Engineers** (ETL workflows) - [ ] Other: [please specify] -## Excel Operations Involved -What Excel APIs or operations would this feature likely use? -- [ ] Power Query (Workbook.Queries) -- [ ] Worksheets (Worksheet operations) -- [ ] Named Ranges (Workbook.Names) -- [ ] Charts/PivotTables +## PowerPoint Operations Involved +What PowerPoint APIs or operations would this feature likely use? +- [ ] Slides (Slide operations) +- [ ] Shapes (Shape operations) +- [ ] Charts - [ ] VBA/Macros (VBProject.VBComponents) -- [ ] External connections -- [ ] Macro-enabled workbooks (.xlsm) +- [ ] Animations/Transitions +- [ ] Macro-enabled presentations (.pptm) - [ ] Other: [please specify] ## Additional context diff --git a/.github/ISSUE_TEMPLATE/mcp_server_issue.md b/.github/ISSUE_TEMPLATE/mcp_server_issue.md index b535f4fe..a1262b6e 100644 --- a/.github/ISSUE_TEMPLATE/mcp_server_issue.md +++ b/.github/ISSUE_TEMPLATE/mcp_server_issue.md @@ -19,9 +19,9 @@ Which AI assistant are you using with the MCP Server? ## MCP Tool & Action Which MCP tool and action are experiencing issues? -- **Tool**: [e.g., powerquery, worksheet, vba, excel_cell, excel_parameter, file] +- **Tool**: [e.g., slide, shape, text, chart, vba, file] - **Action**: [e.g., list, view, import, export, update, refresh, delete, etc.] -- **File Path**: [e.g., "C:\Data\workbook.xlsx"] +- **File Path**: [e.g., "C:\Data\presentation.pptx"] - **Additional Parameters**: [describe any other parameters used] ## Expected Behavior @@ -47,8 +47,8 @@ How is the MCP Server configured? ```json { "mcpServers": { - "excel-mcp": { - "command": "mcp-excel", + "ppt-mcp": { + "command": "mcp-ppt", // or other configuration } } @@ -57,11 +57,11 @@ How is the MCP Server configured? ## Environment - **Windows Version**: [e.g. Windows 11, Windows 10] -- **Excel Version**: [e.g. Excel 365, Excel 2019] -- **ExcelMcp Version**: [e.g. v1.0.0 - run `mcp-excel --version` or `dotnet tool list -g`] +- **PowerPoint Version**: [e.g. PowerPoint 365, PowerPoint 2019] +- **PptMcp Version**: [e.g. v1.0.0 - run `mcp-ppt --version` or `dotnet tool list -g`] - **.NET Version**: [Run `dotnet --version`] - **Installation Method**: - - [ ] Global .NET tool (`dotnet tool install --global ExcelMcp.McpServer`) + - [ ] Global .NET tool (`dotnet tool install --global PptMcp.McpServer`) - [ ] Source build - [ ] Other: [please specify] @@ -80,29 +80,27 @@ If possible, provide relevant logs from the MCP Server: ## Conversation Context (Optional) If helpful, provide the conversation you had with the AI assistant that led to this issue: ``` -User: "Can you list all Power Queries in my workbook?" +User: "Can you list all slides in my presentation?" AI: [response] [MCP Server error occurs] ``` -## Excel File Details -- **File Format**: [.xlsx or .xlsm] +## PowerPoint File Details +- **File Format**: [.pptx or .pptm] - **File Size**: [approximate size] - **Contains**: - - [ ] Power Queries - [ ] VBA Macros - - [ ] Named Ranges - - [ ] Multiple worksheets + - [ ] Multiple slides - [ ] External connections ## VBA-Related Issues (if applicable) -- [ ] VBA trust is properly configured (`ExcelMcp check-vba-trust`) -- [ ] Using .xlsm file format for VBA operations -- [ ] VBA module exists in the workbook +- [ ] VBA trust is properly configured (`PptMcp check-vba-trust`) +- [ ] Using .pptm file format for VBA operations +- [ ] VBA module exists in the presentation - [ ] Macro security settings allow programmatic access ## Additional Context Add any other context about the problem here, including: - Screenshots of AI assistant interaction -- Sample Excel files (with sensitive data removed) +- Sample PowerPoint files (with sensitive data removed) - Other relevant information diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 62356cfc..60f501fe 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -1,16 +1,16 @@ -# mcp-server-excel Development Guidelines +# mcp-server-ppt Development Guidelines Auto-generated from all feature plans. Last updated: 2025-12-19 ## Active Technologies -- C# 14 / .NET 10.0 + ModelContextProtocol, Microsoft.Extensions.*, Application Insights (006-dotnet10-upgrade) -- N/A (Excel files managed via COM) (006-dotnet10-upgrade) +- C# 14 / .NET 10.0 + ModelContextProtocol, Microsoft.Extensions.* (006-dotnet10-upgrade) +- N/A (PowerPoint files managed via COM) (006-dotnet10-upgrade) - Markdown/YAML (documentation only, no code) + None (static files following agentskills.io spec) (007-agent-skills) - N/A (file-based skill package) (007-agent-skills) - Markdown/YAML (documentation-only feature, no .NET code changes) + agentskills.io specification (YAML frontmatter + Markdown body) (007-agent-skills) -- File-based (`skills/excel-mcp/` directory at repo root) (007-agent-skills) +- File-based (`skills/ppt-mcp/` directory at repo root) (007-agent-skills) -- C# / .NET 10 + Excel COM automation via `dynamic` + `ExcelMcp.ComInterop`, MCP SDK (`ModelContextProtocol`), `System.Text.Json`, CLI via `Spectre.Console.Cli` (001-rename-queries-tables) +- C# / .NET 10 + PowerPoint COM automation via `dynamic` + `PptMcp.ComInterop`, MCP SDK (`ModelContextProtocol`), `System.Text.Json`, CLI via `Spectre.Console.Cli` (001-rename-queries-tables) ## Project Structure diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml index 174229b9..8967566a 100644 --- a/.github/codeql/codeql-config.yml +++ b/.github/codeql/codeql-config.yml @@ -1,4 +1,4 @@ -# CodeQL Configuration for ExcelMcp +# CodeQL Configuration for PptMcp # Defines security scanning rules and exclusions # Version: 4.0 - Exclude test code from analysis (production code only) # @@ -10,7 +10,7 @@ # # See CODEQL-FIXES-SUMMARY.md for detailed rationale -name: "ExcelMcp CodeQL Configuration v3.0" +name: "PptMcp CodeQL Configuration v3.0" # Disable default query suite and use custom security-focused queries disable-default-queries: true @@ -48,7 +48,7 @@ query-filters: - exclude: id: cs/catch-of-all-exceptions paths: - - src/ExcelMcp.McpServer/Tools/** + - src/PptMcp.McpServer/Tools/** # ============================================================================ # INTENTIONAL PATTERN: Generic Exception Catches in CLI Commands @@ -58,39 +58,39 @@ query-filters: - exclude: id: cs/catch-of-all-exceptions paths: - - src/ExcelMcp.CLI/Commands/** - - src/ExcelMcp.CLI/Program.cs + - src/PptMcp.CLI/Commands/** + - src/PptMcp.CLI/Program.cs # ============================================================================ # INTENTIONAL PATTERN: COM Interop Exception Handling - # Excel COM automation requires catching exceptions at session/batch boundaries + # PowerPoint COM automation requires catching exceptions at session/batch boundaries # to ensure proper COM cleanup and resource management. # ============================================================================ - exclude: id: cs/catch-of-all-exceptions paths: - - src/ExcelMcp.ComInterop/Session/** + - src/PptMcp.ComInterop/Session/** # ============================================================================ # INTENTIONAL PATTERN: Dynamic COM Calls - # Excel COM interop uses late binding (dynamic) extensively. + # PowerPoint COM interop uses late binding (dynamic) extensively. # These are not invalid calls - they're the standard COM interop pattern. # ============================================================================ - exclude: id: cs/invalid-dynamic-call paths: - - src/ExcelMcp.Core/** - - src/ExcelMcp.ComInterop/** + - src/PptMcp.Core/** + - src/PptMcp.ComInterop/** # ============================================================================ # INTENTIONAL PATTERN: Explicit GC for COM Cleanup # GC.Collect() is required after releasing COM objects to ensure - # Excel process cleanup. This follows Microsoft guidance for COM interop. + # PowerPoint process cleanup. This follows Microsoft guidance for COM interop. # ============================================================================ - exclude: id: cs/call-to-gc paths: - - src/ExcelMcp.ComInterop/** + - src/PptMcp.ComInterop/** # ============================================================================ # AUTO-GENERATED CODE: Regex Generator diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 84994062..ed0b00dc 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,4 +1,4 @@ -# GitHub Copilot Instructions - ExcelMcp +# GitHub Copilot Instructions - PptMcp > **🎯 Optimized for AI Coding Agents** - Modular, path-specific instructions @@ -9,36 +9,36 @@ - [Architecture Patterns](instructions/architecture-patterns.instructions.md) - Batch API, command pattern, resource management **Read based on task type:** -- Adding/fixing commands → [Excel COM Interop](instructions/excel-com-interop.instructions.md) +- Adding/fixing commands → [PowerPoint COM Interop](instructions/ppt-com-interop.instructions.md) - Writing tests → [Testing Strategy](instructions/testing-strategy.instructions.md) - MCP Server work → [MCP Server Guide](instructions/mcp-server-guide.instructions.md) - Creating PR → [Development Workflow](instructions/development-workflow.instructions.md) - Fixing bugs → [Bug Fixing Checklist](instructions/bug-fixing-checklist.instructions.md) **Less frequently needed:** -- [Excel Connection Types](instructions/excel-connection-types-guide.instructions.md) - Only for connection-specific work +- [PowerPoint Connection Types](instructions/ppt-com-patterns-guide.instructions.md) - Only for connection-specific work - [README Management](instructions/readme-management.instructions.md) - Only when updating READMEs - [Documentation Structure](instructions/documentation-structure.instructions.md) - Only when creating docs --- -## What is ExcelMcp? +## What is PptMcp? -**ExcelMcp** is a Windows-only toolset for programmatic Excel automation via COM interop, designed for coding agents and automation scripts. +**PptMcp** is a Windows-only toolset for programmatic PowerPoint automation via COM interop, designed for coding agents and automation scripts. -> **⚠️ CRITICAL: ExcelMcp has TWO equal entry points — MCP Server AND CLI.** +> **⚠️ CRITICAL: PptMcp has TWO equal entry points — MCP Server AND CLI.** > Both are first-class citizens. Every feature, action, and parameter must work identically through both. > When adding/changing features, ALWAYS verify BOTH MCP Server tools AND CLI commands are updated. > See Rule 24 (Post-Change Sync) for the full checklist. **Core Layers:** -1. **ComInterop** (`src/ExcelMcp.ComInterop`) - Reusable COM automation patterns (STA threading, session management, batch operations, OLE message filter) -2. **Core** (`src/ExcelMcp.Core`) - Excel-specific business logic (Power Query, VBA, worksheets, parameters) -3. **Service** (`src/ExcelMcp.Service`) - Excel session management and command routing (in-process for MCP Server, named pipe for CLI daemon) -4. **CLI** (`src/ExcelMcp.CLI`) - Command-line interface for scripting (EQUAL entry point) -5. **MCP Server** (`src/ExcelMcp.McpServer`) - Model Context Protocol for AI assistants (EQUAL entry point) +1. **ComInterop** (`src/PptMcp.ComInterop`) - Reusable COM automation patterns (STA threading, session management, batch operations, OLE message filter) +2. **Core** (`src/PptMcp.Core`) - PowerPoint-specific business logic (slides, shapes, VBA, parameters) +3. **Service** (`src/PptMcp.Service`) - PowerPoint session management and command routing (in-process for MCP Server, named pipe for CLI daemon) +4. **CLI** (`src/PptMcp.CLI`) - Command-line interface for scripting (EQUAL entry point) +5. **MCP Server** (`src/PptMcp.McpServer`) - Model Context Protocol for AI assistants (EQUAL entry point) -**Source Generators** (`src/ExcelMcp.Generators*`) - Generate CLI commands and MCP tools from Core interfaces +**Source Generators** (`src/PptMcp.Generators*`) - Generate CLI commands and MCP tools from Core interfaces --- @@ -53,9 +53,9 @@ dotnet test --filter "Category=Integration&RunType!=OnDemand&Feature!=VBA&Feature!=VBATrust" # Surgical testing - Feature-specific (2-5 minutes per feature) -dotnet test --filter "Feature=PowerQuery&RunType!=OnDemand" -dotnet test --filter "Feature=Ranges&RunType!=OnDemand" -dotnet test --filter "Feature=PivotTables&RunType!=OnDemand" +dotnet test --filter "Feature=Slide&RunType!=OnDemand" +dotnet test --filter "Feature=Shape&RunType!=OnDemand" +dotnet test --filter "Feature=Text&RunType!=OnDemand" # Session/batch changes (MANDATORY) dotnet test --filter "RunType=OnDemand" @@ -65,13 +65,13 @@ dotnet test --filter "RunType=OnDemand" ```csharp // Core: NEVER wrap batch.Execute() in try-catch that returns error result // Let exceptions propagate naturally - batch.Execute() handles them via TaskCompletionSource -public DataType Method(IExcelBatch batch, string arg1) +public DataType Method(IPptBatch batch, string arg1) { return batch.Execute((ctx, ct) => { dynamic? item = null; try { // Operation code here - item = ctx.Book.SomeObject; + item = ctx.Presentation.SomeObject; // For CRUD: return void (throws on error) // For queries: return actual data return someData; @@ -89,7 +89,7 @@ public DataType Method(IExcelBatch batch, string arg1) public int Method(string[] args) { try { - using var batch = ExcelSession.BeginBatch(filePath); + using var batch = PptSession.BeginBatch(filePath); _coreCommands.Method(batch, arg1); return 0; } catch (Exception ex) { @@ -102,7 +102,7 @@ public int Method(string[] args) [Fact] public void TestMethod() { - using var batch = ExcelSession.BeginBatch(_testFile); + using var batch = PptSession.BeginBatch(_testFile); var result = _commands.Method(batch, args); Assert.NotNull(result); // Or other appropriate assertion } @@ -122,9 +122,9 @@ public void TestMethod() **Batch API:** Create NEW simple tests. CLI needs try-catch wrapping. -**Excel Quirks:** Type 3/4 both handle TEXT. `RefreshAll()` unreliable. Use `queryTable.Refresh(false)`. +**PowerPoint Quirks:** Shape Z-order requires explicit reordering. Slide indices are 1-based. Use `Slides.Item(index)` not zero-based access. -**MCP Design:** Prompts are shortcuts, not tutorials. LLMs know Excel/programming. +**MCP Design:** Prompts are shortcuts, not tutorials. LLMs know PowerPoint/programming. **Tool Priority:** `replace_string_in_file` > `grep_search` > `run_in_terminal`. Avoid PowerShell for code. @@ -143,8 +143,8 @@ public void TestMethod() GitHub Copilot auto-loads instructions based on files you're editing: - `tests/**/*.cs` → [Testing Strategy](instructions/testing-strategy.instructions.md) -- `src/ExcelMcp.Core/**/*.cs` → [Excel COM Interop](instructions/excel-com-interop.instructions.md) -- `src/ExcelMcp.McpServer/**/*.cs` → [MCP Server Guide](instructions/mcp-server-guide.instructions.md) +- `src/PptMcp.Core/**/*.cs` → [PowerPoint COM Interop](instructions/ppt-com-interop.instructions.md) +- `src/PptMcp.McpServer/**/*.cs` → [MCP Server Guide](instructions/mcp-server-guide.instructions.md) - `.github/workflows/**/*.yml` → [Development Workflow](instructions/development-workflow.instructions.md) - `**` (all files) → [CRITICAL-RULES.md](instructions/critical-rules.instructions.md) @@ -194,7 +194,7 @@ uv run pytest -m aitest -v # All LLM tests **Prerequisites:** - Azure OpenAI endpoint: `$env:AZURE_OPENAI_ENDPOINT = "https://.openai.azure.com/"` -- Build MCP Server: `dotnet build src\ExcelMcp.McpServer -c Release` +- Build MCP Server: `dotnet build src\PptMcp.McpServer -c Release` **Structure:** - `test_mcp_*.py` - MCP Server workflows @@ -209,8 +209,8 @@ Two cross-platform AI assistant skill packages: | Skill | File | Target | Best For | |-------|------|--------|----------| -| **excel-cli** | `skills/excel-cli/SKILL.md` | CLI Tool | Coding agents (token-efficient, `--help` discoverable) | -| **excel-mcp** | `skills/excel-mcp/SKILL.md` | MCP Server | Conversational AI (rich tool schemas) | +| **ppt-cli** | `skills/ppt-cli/SKILL.md` | CLI Tool | Coding agents (token-efficient, `--help` discoverable) | +| **ppt-mcp** | `skills/ppt-mcp/SKILL.md` | MCP Server | Conversational AI (rich tool schemas) | **Build skills from source:** ```powershell @@ -219,14 +219,14 @@ dotnet build -c Release # Generates SKILL.md, copies references, and generates **Guidance architecture (single source of truth):** - `skills/shared/*.md` → auto-copied to skill references AND auto-generated as MCP prompts -- Skill-based clients (VS Code, Cursor) read `skills/excel-*/references/` +- Skill-based clients (VS Code, Cursor) read `skills/ppt-*/references/` - MCP-only clients (Claude Desktop) read auto-generated `[McpServerPrompt]` methods - NEVER create separate prompt files for content that belongs in `skills/shared/` **Install via npx:** ```bash -npx skills add sbroenne/mcp-server-excel --skill excel-cli # Coding agents -npx skills add sbroenne/mcp-server-excel --skill excel-mcp # Conversational AI +npx skills add trsdn/mcp-server-ppt --skill ppt-cli # Coding agents +npx skills add trsdn/mcp-server-ppt --skill ppt-mcp # Conversational AI ``` --- @@ -235,11 +235,11 @@ npx skills add sbroenne/mcp-server-excel --skill excel-mcp # Conversational AI ### Command File Structure ``` -Commands/Sheet/ -├── ISheetCommands.cs # Interface (defines contract) -├── SheetCommands.cs # Partial class (constructor, DI) -├── SheetCommands.Lifecycle.cs # Partial (Create, Delete, Rename...) -└── SheetCommands.Style.cs # Partial (formatting operations) +Commands/Slide/ +├── ISlideCommands.cs # Interface (defines contract) +├── SlideCommands.cs # Partial class (constructor, DI) +├── SlideCommands.Lifecycle.cs # Partial (Create, Delete, Rename...) +└── SlideCommands.Style.cs # Partial (formatting operations) ``` **Rules:** @@ -265,13 +265,13 @@ catch (Exception ex) { ### Service Architecture (TWO EQUAL ENTRY POINTS) ``` -MCP Server ──► In-process ExcelMcpService ──► Core Commands ──► Excel COM -CLI ─────────► CLI Daemon (named pipe) ─────► Core Commands ──► Excel COM +MCP Server ──► In-process PptMcpService ──► Core Commands ──► PowerPoint COM +CLI ─────────► CLI Daemon (named pipe) ─────► Core Commands ──► PowerPoint COM ``` -**⚠️ MCP Server and CLI are BOTH first-class entry points.** Each hosts its own ExcelMcpService instance: +**⚠️ MCP Server and CLI are BOTH first-class entry points.** Each hosts its own PptMcpService instance: - **MCP Server**: Fully in-process, direct method calls (no pipe) -- **CLI**: Daemon process with named pipe (`excelmcp-cli-{SID}`), sessions persist across CLI invocations +- **CLI**: Daemon process with named pipe (`PptMcp-cli-{SID}`), sessions persist across CLI invocations - **Feature parity**: Every action available in MCP must be available in CLI and vice versa - **Parameter parity**: Same parameters, same defaults, same validation diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b4856e09..a25e3296 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,4 +1,4 @@ -# GitHub Dependabot configuration for ExcelMcp +# GitHub Dependabot configuration for PptMcp # Automatically keeps dependencies up-to-date and secure version: 2 @@ -20,7 +20,7 @@ updates: prefix: "chore(deps)" include: "scope" reviewers: - - "sbroenne" + - "trsdn" # Group minor and patch updates together groups: production-dependencies: @@ -58,7 +58,7 @@ updates: prefix: "chore(actions)" include: "scope" reviewers: - - "sbroenne" + - "trsdn" groups: github-actions: patterns: @@ -80,4 +80,4 @@ updates: commit-message: prefix: "chore(docker)" reviewers: - - "sbroenne" + - "trsdn" diff --git a/.github/instructions/architecture-patterns.instructions.md b/.github/instructions/architecture-patterns.instructions.md index 018e84b4..782220d9 100644 --- a/.github/instructions/architecture-patterns.instructions.md +++ b/.github/instructions/architecture-patterns.instructions.md @@ -4,7 +4,7 @@ applyTo: "src/**/*.cs" # Architecture Patterns -> **Core patterns for ExcelMcp development** +> **Core patterns for PptMcp development** ## .NET Class Design (MANDATORY) @@ -38,7 +38,7 @@ Commands/Range/ ## TWO EQUAL ENTRY POINTS (CRITICAL) -**ExcelMcp has TWO first-class entry points: MCP Server AND CLI.** Both must have: +**PptMcp has TWO first-class entry points: MCP Server AND CLI.** Both must have: - **Feature parity**: Every action in MCP must exist in CLI and vice versa - **Parameter parity**: Same parameters, same defaults, same validation - **Behavior parity**: Same Core command, same result format @@ -46,8 +46,8 @@ Commands/Range/ When adding or changing ANY feature, ALWAYS update BOTH entry points. See Rule 24 (Post-Change Sync). ``` -MCP Server (MCP tools, JSON-RPC) ──► In-process ExcelMcpService ──► Core Commands ──► Excel COM -CLI (command-line args, console) ──► CLI Daemon (named pipe) ─────► Core Commands ──► Excel COM +MCP Server (MCP tools, JSON-RPC) ──► In-process PptMcpService ──► Core Commands ──► PowerPoint COM +CLI (command-line args, console) ──► CLI Daemon (named pipe) ─────► Core Commands ──► PowerPoint COM ``` --- @@ -57,16 +57,16 @@ CLI (command-line args, console) ──► CLI Daemon (named pipe) ──── ### Structure ``` Commands/ -├── IPowerQueryCommands.cs # Interface -├── PowerQueryCommands.cs # Implementation +├── ISlideCommands.cs # Interface +├── SlideCommands.cs # Implementation ``` ### Routing (Program.cs) ```csharp return args[0] switch { - "pq-list" => powerQuery.List(args), - "sheet-read" => sheet.Read(args), + "slide-list" => slide.List(args), + "shape-read" => shape.Read(args), _ => ShowHelp() }; ``` @@ -75,7 +75,7 @@ return args[0] switch ## Resource Management Pattern -**See excel-com-interop.instructions.md** for complete WithExcel() pattern and COM object lifecycle management. +**See ppt-com-interop.instructions.md** for complete WithPowerPoint() pattern and COM object lifecycle management. --- @@ -85,7 +85,7 @@ return args[0] switch ```csharp // ❌ WRONG: Suppressing exception with catch block -public async Task SomeAsync(IExcelBatch batch, string param) +public async Task SomeAsync(IPptBatch batch, string param) { try { @@ -106,7 +106,7 @@ public async Task SomeAsync(IExcelBatch batch, string param) } // ✅ CORRECT: Let exception propagate through batch.Execute() -public async Task SomeAsync(IExcelBatch batch, string param) +public async Task SomeAsync(IPptBatch batch, string param) { return await batch.Execute((ctx, ct) => { // ... operation ... @@ -117,22 +117,22 @@ public async Task SomeAsync(IExcelBatch batch, string param) } // ✅ CORRECT: Finally blocks still allowed for COM resource cleanup -public async Task ComplexAsync(IExcelBatch batch, string param) +public async Task ComplexAsync(IPptBatch batch, string param) { - dynamic? connection = null; + dynamic? shapeRef = null; try { return await batch.Execute((ctx, ct) => { - connection = ctx.Book.Connections.Add(...); + shapeRef = ctx.Presentation.Slides[1].Shapes.AddShape(...); // ... operation ... return ValueTask.FromResult(new OperationResult { Success = true }); }); } finally { - if (connection != null) + if (shapeRef != null) { - ComUtilities.Release(ref connection!); // ✅ Cleanup in finally + ComUtilities.Release(ref shapeRef!); // ✅ Cleanup in finally } } } @@ -150,47 +150,46 @@ public async Task ComplexAsync(IExcelBatch batch, string param) ## MCP Server Resource-Based Tools -**In-Process Architecture**: MCP Server hosts ExcelMcpService fully in-process with direct method calls (no pipe). +**In-Process Architecture**: MCP Server hosts PptMcpService fully in-process with direct method calls (no pipe). ServiceBridge holds the service reference and calls ProcessAsync() directly. **19 Focused Tools:** 1. `file` - Session lifecycle (open, close, create, list) -2. `worksheet` - Worksheet operations -3. `worksheet_style` - Tab colors and visibility -4. `range` - Range values and formulas -5. `range_edit` - Insert/delete/find/replace -6. `table` - Excel Tables (ListObjects) -7. `table_column` - Table columns/filters/sorts -8. `powerquery` - Power Query M code -9. `pivottable` - PivotTable lifecycle -10. `pivottable_field` - PivotTable fields -11. `pivottable_calc` - Calculated fields/items -12. `chart` - Chart lifecycle -13. `chart_config` - Chart configuration -14. `connection` - Data connections -15. `slicer` - Slicers +2. `slide` - Slide operations +3. `slide_style` - Slide layout and background +4. `shape` - Shape operations (add, modify, delete) +5. `text` - Text and TextFrame operations +6. `table` - Table operations on slides +7. `image` - Image and picture operations +8. `chart` - Chart lifecycle +9. `chart_config` - Chart configuration +10. `animation` - Animation effects +11. `transition` - Slide transitions +12. `slide_master` - Slide master and layout management +13. `notes` - Speaker notes +14. `section` - Presentation sections +15. `media` - Audio and video operations 16. `vba` - VBA macros -17. `datamodel` - Power Pivot / DAX -18. `datamodel_relationship` - Data Model relationships -19. `namedrange` - Named ranges -20. `excel_calculation` - Calculation mode +17. `comment` - Slide comments +18. `export` - Export slides (images, PDF) +19. `hyperlink` - Hyperlink operations ### Action-Based Routing with ForwardToService ```csharp [McpServerTool] -public static string ExcelPowerQuery(string action, string sessionId, ...) +public static string PptSlide(string action, string sessionId, ...) { return action.ToLowerInvariant() switch { "list" => ForwardList(sessionId), - "view" => ForwardView(sessionId, queryName), + "get" => ForwardGet(sessionId, slideIndex), _ => throw new McpException($"Unknown action: {action}") }; } private static string ForwardList(string sessionId) { - return ExcelToolsBase.ForwardToService("powerquery.list", sessionId); + return PptToolsBase.ForwardToService("slide.list", sessionId); } ``` @@ -198,7 +197,7 @@ private static string ForwardList(string sessionId) ## DRY Shared Utilities -**ExcelHelper Methods:** `FindConnection()`, `FindQuery()`, `GetConnectionTypeName()`, `IsPowerQueryConnection()`, `CreateQueryTable()`, `SanitizeConnectionString()` +**PptHelper Methods:** `FindSlide()`, `FindShape()`, `GetShapeTypeName()`, `GetSlideLayout()` **Why:** Prevents 60+ lines of duplicate code per feature @@ -207,9 +206,6 @@ private static string ForwardList(string sessionId) ## Security-First Patterns ```csharp -// Always sanitize before output -string safe = SanitizeConnectionString(connectionString); - // Defaults SavePassword = false // Never export credentials by default ``` @@ -218,17 +214,17 @@ SavePassword = false // Never export credentials by default ## Performance Patterns -**Minimize workbook opens** - Use single session for multiple operations -**Bulk operations** - Use `range.Value2` for 2D arrays, not cell-by-cell access +**Minimize presentation opens** - Use single session for multiple operations +**Bulk operations** - Minimize COM round-trips by batching shape/slide operations --- ## Key Principles -1. **WithExcel() for everything** - See excel-com-interop.instructions.md -2. **Release intermediate objects** - Prevents Excel hanging +1. **WithPowerPoint() for everything** - See ppt-com-interop.instructions.md +2. **Release intermediate objects** - Prevents PowerPoint hanging 3. **Batch/Session for MCP** - Multiple operations in single session -4. **Resource-based tools** - 22 tools, not 33+ operations +4. **Resource-based tools** - 19 tools, not 33+ operations 5. **DRY utilities** - Share common patterns 6. **Security defaults** - Never expose credentials 7. **Bulk operations** - Minimize COM round-trips diff --git a/.github/instructions/coverage-prevention-strategy.instructions.md b/.github/instructions/coverage-prevention-strategy.instructions.md index bcb70995..d93aaee6 100644 --- a/.github/instructions/coverage-prevention-strategy.instructions.md +++ b/.github/instructions/coverage-prevention-strategy.instructions.md @@ -1,5 +1,5 @@ --- -applyTo: "src/ExcelMcp.Core/Commands/**/*.cs,src/ExcelMcp.McpServer/**/*.cs" +applyTo: "src/PptMcp.Core/Commands/**/*.cs,src/PptMcp.McpServer/**/*.cs" --- # Core Commands Coverage - Mandatory Workflow @@ -23,29 +23,29 @@ applyTo: "src/ExcelMcp.Core/Commands/**/*.cs,src/ExcelMcp.McpServer/**/*.cs" ```markdown 1. ✅ Add method to Core Commands interface - File: src/ExcelMcp.Core/Commands/[Feature]/I[Feature]Commands.cs - Example: Task NewMethodAsync(IExcelBatch batch); + File: src/PptMcp.Core/Commands/[Feature]/I[Feature]Commands.cs + Example: Task NewMethodAsync(IPptBatch batch); 2. ✅ Implement in Core Commands class - File: src/ExcelMcp.Core/Commands/[Feature]/[Feature]Commands.cs + File: src/PptMcp.Core/Commands/[Feature]/[Feature]Commands.cs 3. ✅ Add enum value to ToolActions.cs - File: src/ExcelMcp.McpServer/Models/ToolActions.cs - Example: PowerQueryAction.NewMethod + File: src/PptMcp.McpServer/Models/ToolActions.cs + Example: SlideAction.NewMethod ⚠️ Build will show CS8524 error until steps 4-6 complete 4. ✅ Add ToActionString mapping - File: src/ExcelMcp.McpServer/Models/ActionExtensions.cs - Example: PowerQueryAction.NewMethod => "new-method", + File: src/PptMcp.McpServer/Models/ActionExtensions.cs + Example: SlideAction.NewMethod => "new-method", ⚠️ CS8524 error persists 5. ✅ Add switch case in MCP Tool - File: src/ExcelMcp.McpServer/Tools/Excel[Feature]Tool.cs - Example: PowerQueryAction.NewMethod => await NewMethodAsync(...), + File: src/PptMcp.McpServer/Tools/Ppt[Feature]Tool.cs + Example: SlideAction.NewMethod => await NewMethodAsync(...), ⚠️ CS8524 error persists 6. ✅ Implement MCP method - File: src/ExcelMcp.McpServer/Tools/Excel[Feature]Tool.cs + File: src/PptMcp.McpServer/Tools/Ppt[Feature]Tool.cs Example: private static async Task NewMethodAsync(...) ✅ CS8524 errors resolved @@ -67,27 +67,27 @@ applyTo: "src/ExcelMcp.Core/Commands/**/*.cs,src/ExcelMcp.McpServer/**/*.cs" ```csharp // Step 3: Add enum value (compiler checks this) -public enum PowerQueryAction +public enum SlideAction { List, - View, + Get, NewMethod // ⚠️ Forget this → CS8524 error in ActionExtensions.cs } // Step 4: Add ToActionString mapping (compiler checks this) -public static string ToActionString(this PowerQueryAction action) => action switch +public static string ToActionString(this SlideAction action) => action switch { - PowerQueryAction.List => "list", - PowerQueryAction.View => "view", - PowerQueryAction.NewMethod => "new-method", // ⚠️ Forget this → CS8524 error + SlideAction.List => "list", + SlideAction.Get => "get", + SlideAction.NewMethod => "new-method", // ⚠️ Forget this → CS8524 error }; // Step 5: Add switch case in Tool (compiler checks this) return action switch { - PowerQueryAction.List => await ListAsync(...), - PowerQueryAction.View => await ViewAsync(...), - PowerQueryAction.NewMethod => await NewMethodAsync(...), // ⚠️ Forget this → CS8524 error + SlideAction.List => await ListAsync(...), + SlideAction.Get => await GetAsync(...), + SlideAction.NewMethod => await NewMethodAsync(...), // ⚠️ Forget this → CS8524 error }; ``` @@ -148,12 +148,12 @@ git commit --no-verify -m "Message" ``` Interface CoreMethods EnumValues Gap Status --------- ----------- ---------- --- ------ -IPowerQueryCommands 18 18 0 ✅ -ISheetCommands 13 13 0 ✅ -IRangeCommands 42 42 0 ✅ -ITableCommands 23 23 0 ✅ +ISlideCommands 15 15 0 ✅ +IShapeCommands 20 20 0 ✅ +ITableCommands 12 12 0 ✅ +IChartCommands 18 18 0 ✅ -Summary: 100% coverage ✅ (156 Core methods, 156 enum values) +Summary: 100% coverage ✅ (65 Core methods, 65 enum values) ``` **When gaps detected**: @@ -162,7 +162,7 @@ Interface CoreMethods EnumValues Gap Status --------- ----------- ---------- --- ------ IRangeCommands 42 40 2 ❌ -Summary: 98.7% coverage (156 Core methods, 154 enum values, 2 gaps) +Summary: 98.7% coverage (65 Core methods, 63 enum values, 2 gaps) ``` **Fix**: Follow 8-step workflow. diff --git a/.github/instructions/critical-rules.instructions.md b/.github/instructions/critical-rules.instructions.md index dc988f5f..1dac4110 100644 --- a/.github/instructions/critical-rules.instructions.md +++ b/.github/instructions/critical-rules.instructions.md @@ -4,7 +4,7 @@ applyTo: "**" # CRITICAL RULES - MUST FOLLOW -> **⚠️ NON-NEGOTIABLE rules for all ExcelMcp development** +> **⚠️ NON-NEGOTIABLE rules for all PptMcp development** ## Rule 0: NEVER Commit Without Running Tests (CRITICAL) @@ -46,22 +46,22 @@ applyTo: "**" **Forbidden in commits/PRs/issues:** - Customer project names (e.g., "CP Toolkit", "Contoso Deal") -- Specific file paths from customer projects (e.g., "MSX Plan.xlsx", "Milestone_Export") +- Specific file paths from customer projects (e.g., "MSX Plan.pptx", "Milestone_Export") - Internal tool names that reveal customer context - Any information that could identify a specific customer engagement **Allowed:** -- Generic descriptions ("a Power Query", "an Excel workbook") +- Generic descriptions ("a slide shape", "a PowerPoint presentation") - Technical details that don't identify the source ("a column with a hyphen in the name") - Error messages and stack traces (sanitized of paths/names) **Example:** ``` # ❌ WRONG: Reveals confidential project -Discovered while debugging Milestone_Export query in CP Toolkit's MSX Plan.xlsx +Discovered while debugging Milestone_Export query in CP Toolkit's MSX Plan.pptx # ✅ CORRECT: Generic description -Discovered while debugging a Power Query that referenced a column with a hyphen +Discovered while debugging a slide shape that referenced an embedded object ``` **Enforcement:** @@ -90,7 +90,7 @@ Discovered while debugging a Power Query that referenced a column with a hyphen | 29. TDD | Write test FIRST → RED → implement → GREEN | Proves tests catch real bugs | | 30. Integration tests | NEVER write unit tests — integration tests only | Unit tests prove nothing for COM interop | | 22. COM cleanup | ALWAYS use try-finally, NEVER swallow exceptions | Prevents leaks and silent failures | -| 7. COM API | Use Excel COM first, validate docs | Prevents wrong dependencies | +| 7. COM API | Use PowerPoint COM first, validate docs | Prevents wrong dependencies | | 9. GitHub search | Search OTHER repos for VBA/COM examples FIRST | Learn from working code | | 2. NotImplementedException | Never use, full implementation only | No placeholders allowed | | 15. Enum mappings | All enum values mapped in ToActionString() | Runtime errors otherwise | @@ -139,7 +139,7 @@ Discovered while debugging a Power Query that referenced a column with a hyphen ```csharp // ❌ CRITICAL BUG: Confuses LLMs and users result.Success = true; -result.ErrorMessage = "Query imported but failed to load..."; +result.ErrorMessage = "Shape added but failed to format..."; // ✅ CORRECT: Success only when NO errors if (!loadResult.Success) { @@ -183,12 +183,12 @@ try { **Enforcement:** - Pre-commit hook runs `check-success-flag.ps1` to detect violations -- Regression tests verify this invariant (PowerQuerySuccessErrorRegressionTests) +- Regression tests verify this invariant (SlideSuccessErrorRegressionTests) - Code review MUST check every `Success = ` assignment - Search pattern: `Success.*true.*ErrorMessage` **Examples of bugs found:** -- 43 violations across Connection, PowerQuery, DataModel, VBA, Range, Table commands +- 43 violations across Slide, Shape, Text, VBA, Chart, Animation commands - All followed pattern: `Success = true` at start, `ErrorMessage` set in catch without `Success = false` --- @@ -199,13 +199,13 @@ try { ```csharp // ❌ CRITICAL BUG: Suppressing exceptions with error result -public async Task CreateAsync(IExcelBatch batch, string name) +public async Task CreateAsync(IPptBatch batch, string name) { try { return await batch.Execute((ctx, ct) => { - var sheet = ctx.Book.Worksheets.Add(); - sheet.Name = name; + var slide = ctx.Presentation.Slides.Add(1, 1); + slide.Name = name; return ValueTask.FromResult(new OperationResult { Success = true }); }); } @@ -221,18 +221,18 @@ public async Task CreateAsync(IExcelBatch batch, string name) } // ✅ CORRECT: Let exception propagate to batch.Execute() -public async Task CreateAsync(IExcelBatch batch, string name) +public async Task CreateAsync(IPptBatch batch, string name) { return await batch.Execute((ctx, ct) => { - var sheet = ctx.Book.Worksheets.Add(); - sheet.Name = name; + var slide = ctx.Presentation.Slides.Add(1, 1); + slide.Name = name; return ValueTask.FromResult(new OperationResult { Success = true }); }); // batch.Execute() catches via TaskCompletionSource → returns OperationResult { Success = false } } // ✅ CORRECT: Finally blocks are allowed for COM resource cleanup -public async Task ComplexAsync(IExcelBatch batch, dynamic item) +public async Task ComplexAsync(IPptBatch batch, dynamic item) { dynamic? temp = null; try @@ -283,7 +283,7 @@ Core Command Method (NO try-catch wrapping) ## Rule 2: No NotImplementedException -**Every feature must be fully implemented with real Excel COM operations and passing tests. No placeholders.** +**Every feature must be fully implemented with real PowerPoint COM operations and passing tests. No placeholders.** --- @@ -305,7 +305,7 @@ Core Command Method (NO try-catch wrapping) All `dynamic` COM objects must be released in `finally` blocks using `ComUtilities.Release(ref obj!)`. -Exception: Session management files (ExcelBatch.cs, ExcelSession.cs). +Exception: Session management files (PptBatch.cs, PptSession.cs). --- @@ -328,9 +328,9 @@ git commit -m "your message" # Commit to feature branch ## Rule 7: COM API First -**Use Excel COM API for everything it supports. Only use external libraries (TOM) for features Excel COM doesn't provide.** +**Use PowerPoint COM API for everything it supports. Only use external libraries (TOM) for features PowerPoint COM doesn't provide.** -Validate against [Microsoft docs](https://learn.microsoft.com/office/vba/api/overview/excel) before adding dependencies. +Validate against [Microsoft docs](https://learn.microsoft.com/office/vba/api/overview/powerpoint) before adding dependencies. --- @@ -344,21 +344,21 @@ Delete commented-out code (use git history). Exception: Documentation files only ## Rule 9: Search External GitHub Repositories for Working Examples First -**BEFORE creating new Excel COM Interop code or troubleshooting COM issues:** +**BEFORE creating new PowerPoint COM Interop code or troubleshooting COM issues:** - **ALWAYS** search OTHER open source GitHub repositories for working examples - **NEVER** search your own repository - only search external projects - **NetOffice is THE BEST source for ALL COM Interop work**: https://github.com/NetOfficeFw/NetOffice - Strongly-typed C# wrappers for ALL Office COM APIs (Excel, Word, PowerPoint, Outlook, etc.) - - Search for ANY Excel COM operation: ranges, worksheets, PivotTables, Power Query, charts, VBA, connections, formatting, etc. + - Search for ANY PowerPoint COM operation: slides, shapes, animations, transitions, text frames, formatting, etc. - Study their patterns for dynamic interop conversion and proper COM object handling - - NetOffice source code is essentially a comprehensive reference for every Excel COM API -- Look for repositories with Excel automation, VBA code, or Office interop projects -- Search for the specific COM object/method you need (e.g., "PivotTable CreatePivotTable VBA", "QueryTable Refresh VBA", "Range.Value2 NetOffice") + - NetOffice source code is essentially a comprehensive reference for every PowerPoint COM API +- Look for repositories with PowerPoint automation, VBA code, or Office interop projects +- Search for the specific COM object/method you need (e.g., "Slide AddShape VBA", "Shape TextFrame VBA", "Presentation.Slides NetOffice") - Study proven patterns from other projects before writing new code - Avoid reinventing solutions - learn from working implementations in the wild -**Why:** Excel COM is quirky. Real-world VBA examples from other projects prevent common pitfalls (1-based indexing, object cleanup, async issues, variant types, etc.) +**Why:** PowerPoint COM is quirky. Real-world VBA examples from other projects prevent common pitfalls (1-based indexing, object cleanup, async issues, variant types, etc.) --- @@ -382,7 +382,7 @@ Delete commented-out code (use git history). Exception: Documentation files only **Violations:** - ❌ `` in production `.csproj` -- ❌ `using Sbroenne.ExcelMcp.*.Tests` in production code +- ❌ `using PptMcp.*.Tests` in production code - ❌ Production code calling test helper methods - ❌ Production business logic in helper classes that tests use @@ -404,9 +404,9 @@ Delete commented-out code (use git history). Exception: Documentation files only - ✅ Uses `IClassFixture` (NOT manual IDisposable) - ✅ Each test creates unique file via `CoreTestHelper.CreateUniqueTestFile()` - ✅ NEVER shares test files between tests -- ✅ VBA tests use `.xlsm` extension (NOT .xlsx renamed) +- ✅ VBA tests use `.pptm` extension (NOT .pptx renamed) - ✅ Binary assertions only (NO "accept both" patterns) -- ✅ All required traits present (Category, Speed, Layer, RequiresExcel, Feature) +- ✅ All required traits present (Category, Speed, Layer, RequiresPowerPoint, Feature) - ✅ Batch API pattern used correctly (no ValueTask.FromResult wrapper) - ✅ NO duplicate helper methods (use CoreTestHelper) @@ -432,7 +432,7 @@ Delete commented-out code (use git history). Exception: Documentation files only **Why:** Incomplete bug fixes lead to regressions, confusion, and wasted time. Comprehensive fixes prevent future issues. -**Example:** Refresh + loadDestination bug = 1 code file + 13 tests + 5 doc files + detailed PR description = complete fix. +**Example:** Shape rotation + positioning bug = 1 code file + 13 tests + 5 doc files + detailed PR description = complete fix. --- @@ -442,7 +442,7 @@ Delete commented-out code (use git history). Exception: Documentation files only **Quick Rules:** - ❌ FORBIDDEN: Tests only verifying operation success or in-memory state -- ✅ REQUIRED: Round-trip tests verifying data persists after workbook close/reopen +- ✅ REQUIRED: Round-trip tests verifying data persists after presentation close/reopen - ⚡ REASON: Save is slow (~2-5s). Removing unnecessary saves makes tests 50%+ faster **See:** [testing-strategy.instructions.md](testing-strategy.instructions.md) for complete Save patterns, when to use, and detailed examples. @@ -460,7 +460,7 @@ Delete commented-out code (use git history). Exception: Documentation files only | 4. Instructions | Update after significant work | 5-10 min | | 5. COM leaks | Run `scripts\check-com-leaks.ps1` | 1 min | | 6. PRs | Always use PRs, never direct commit | Always | -| 7. COM API | Use Excel COM first, validate docs | Always | +| 7. COM API | Use PowerPoint COM first, validate docs | Always | | 8. TODO markers | Must resolve before commit | 1 min | | 9. GitHub search | Search OTHER repos for VBA/COM examples FIRST | 1-2 min | | 10. Test debugging | Run tests one by one, never all together | Per test | @@ -532,12 +532,12 @@ dotnet test --filter "Category=Integration&RunType!=OnDemand" **Correct:** ```bash # ✅ CORRECT: Test only the feature you changed -dotnet test --filter "Feature=PowerQuery&RunType!=OnDemand" # PowerQuery changes only -dotnet test --filter "Feature=Connection&RunType!=OnDemand" # Connection changes only -dotnet test --filter "Feature=Sheet&RunType!=OnDemand" # Sheet changes only +dotnet test --filter "Feature=Slide&RunType!=OnDemand" # Slide changes only +dotnet test --filter "Feature=Shape&RunType!=OnDemand" # Shape changes only +dotnet test --filter "Feature=Text&RunType!=OnDemand" # Text changes only ``` -**Why Critical:** Integration tests require Excel COM automation and are SLOW. Running all tests wastes time and resources. +**Why Critical:** Integration tests require PowerPoint COM automation and are SLOW. Running all tests wastes time and resources. **Enforcement:** - Only run tests for files you modified @@ -587,10 +587,10 @@ private static async Task SomeAction(...) **Example - Business Error (return JSON):** ```csharp -// Core returns: { Success = false, ErrorMessage = "Table 'Sales' not found" } +// Core returns: { Success = false, ErrorMessage = "Shape 'Title' not found" } // MCP Tool: Return this as-is return JsonSerializer.Serialize(result, JsonOptions); -// Client gets: {"success": false, "errorMessage": "Table 'Sales' not found"} +// Client gets: {"success": false, "errorMessage": "Shape 'Title' not found"} ``` **Example - Validation Error (throw exception):** @@ -615,38 +615,38 @@ if (string.IsNullOrWhiteSpace(tableName)) 1. **Purpose and Use Cases Clear**: ```csharp // ❌ WRONG: Vague description - /// Manage worksheets + /// Manage slides // ✅ CORRECT: Clear purpose and use cases /// - /// Manage Excel worksheet lifecycle: create, rename, copy, delete sheets. + /// Manage PowerPoint slide lifecycle: create, rename, copy, delete slides. /// ``` 2. **Non-Enum Parameter Values Documented**: ```csharp // ❌ WRONG: Parameter values not explained - /// Import Power Query with loadDestination parameter + /// Add shape with shapeType parameter // ✅ CORRECT: Non-enum parameter values explained /// - /// Import Power Query. + /// Add shape to slide. /// - /// LOAD DESTINATIONS: - /// - 'worksheet': Load to worksheet (DEFAULT) - /// - 'data-model': Load to Power Pivot - /// - 'both': Load to BOTH - /// - 'connection-only': Don't load data + /// SHAPE TYPES: + /// - 'rectangle': Add rectangle shape (DEFAULT) + /// - 'oval': Add oval shape + /// - 'textbox': Add text box + /// - 'line': Add line shape /// ``` 3. **Server-Specific Behavior Documented**: ```csharp // ❌ WRONG: Behavior changed but description outdated - /// Default: loadDestination='connection-only' // Wrong! + /// Default: shapeType='line' // Wrong! // ✅ CORRECT: Description reflects actual default - /// Default: loadDestination='worksheet' + /// Default: shapeType='rectangle' ``` **What NOT to include:** @@ -661,7 +661,7 @@ if (string.IsNullOrWhiteSpace(tableName)) **When to Update:** - Changing default values or server behavior -- Adding/changing non-enum parameter values (loadDestination, formatCode, etc.) +- Adding/changing non-enum parameter values (shapeType, transitionType, etc.) - Changing which tools to use for related operations - Adding performance guidance (batch mode) @@ -678,10 +678,10 @@ if (string.IsNullOrWhiteSpace(tableName)) # ⚠️ IMPORTANT: gh CLI requires authentication with a PERSONAL GitHub account. # Enterprise Managed User (EMU) accounts cannot access public repos via gh CLI. # Use: gh auth login --with-token (with a personal access token) -gh api repos/sbroenne/mcp-server-excel/pulls/PULL_NUMBER/comments --paginate +gh api repos/trsdn/mcp-server-ppt/pulls/PULL_NUMBER/comments --paginate # Or use the mcp_github tool if available -mcp_github_github_pull_request_read(method="get_review_comments", owner="sbroenne", repo="mcp-server-excel", pullNumber=PULL_NUMBER) +mcp_github_github_pull_request_read(method="get_review_comments", owner="trsdn", repo="mcp-server-ppt", pullNumber=PULL_NUMBER) ``` **Common automated reviewers:** @@ -724,11 +724,11 @@ mcp_github_github_pull_request_read(method="get_review_comments", owner="sbroenn // ❌ WRONG: Swallows exception, sets fallback value try { - dynamic pivotLayout = chart.PivotLayout; - dynamic pivotTable = pivotLayout.PivotTable; - name = pivotTable.Name?.ToString() ?? string.Empty; - ComUtilities.Release(ref pivotTable!); // Won't execute if exception occurs! - ComUtilities.Release(ref pivotLayout!); + dynamic slideLayout = slide.CustomLayout; + dynamic shapePlaceholder = slideLayout.SlideMaster; + name = shapePlaceholder.Name?.ToString() ?? string.Empty; + ComUtilities.Release(ref shapePlaceholder!); // Won't execute if exception occurs! + ComUtilities.Release(ref slideLayout!); } catch { @@ -736,18 +736,18 @@ catch } // ✅ CORRECT: Finally ensures cleanup, exceptions propagate -dynamic? pivotLayout = null; -dynamic? pivotTable = null; +dynamic? slideLayout = null; +dynamic? shapePlaceholder = null; try { - pivotLayout = chart.PivotLayout; - pivotTable = pivotLayout.PivotTable; - name = pivotTable.Name?.ToString() ?? string.Empty; + slideLayout = slide.CustomLayout; + shapePlaceholder = slideLayout.SlideMaster; + name = shapePlaceholder.Name?.ToString() ?? string.Empty; } finally { - if (pivotTable != null) ComUtilities.Release(ref pivotTable!); - if (pivotLayout != null) ComUtilities.Release(ref pivotLayout!); + if (shapePlaceholder != null) ComUtilities.Release(ref shapePlaceholder!); + if (slideLayout != null) ComUtilities.Release(ref slideLayout!); } // Exception propagates naturally, COM objects always released ``` @@ -769,7 +769,7 @@ finally **See Also:** - Rule 1b: Exception propagation pattern -- excel-com-interop.instructions.md for complete patterns +- ppt-com-interop.instructions.md for complete patterns --- @@ -835,11 +835,11 @@ When adding a NEW action to an existing tool: | 2. Mapping | `ActionExtensions.cs` - Add ToActionString() case | | 3. Interface | `I*Commands.cs` - Add interface method | | 4. Core | `*Commands.*.cs` - Implement method | -| 5. MCP Server | `Excel*Tool.cs` - Add switch case + handler | -| 6. CLI Daemon | `ExcelDaemon.cs` - Add switch case | +| 5. MCP Server | `Ppt*Tool.cs` - Add switch case + handler | +| 6. CLI Daemon | `PptDaemon.cs` - Add switch case | | 7. Feature Count | `FEATURES.md` - Update operation count | | 8. README Files | All READMEs with operation counts (main, MCP, CLI, mcpb, vscode) | -| 9. Skills Docs | `skills/shared/excel_*.md` - Document new action | +| 9. Skills Docs | `skills/shared/ppt_*.md` - Document new action | **Quick Check Commands:** ```powershell @@ -862,13 +862,13 @@ grep -r "209 operations\|210 operations\|10 ops\|11 ops" --include="*.md" - Before EVERY commit that touches tool/action code **Historical Example (Jan 2026):** -PowerQuery `unload` action was added to: +Slide `duplicate` action was added to: - ✅ ToolActions.cs enum - ✅ ActionExtensions.cs mapping -- ✅ IPowerQueryCommands.cs interface -- ✅ PowerQueryCommands.Lifecycle.cs implementation -- ✅ ExcelPowerQueryTool.cs MCP handler -- ❌ ExcelDaemon.cs CLI handler (MISSED!) +- ✅ ISlideCommands.cs interface +- ✅ SlideCommands.Lifecycle.cs implementation +- ✅ PptSlideTool.cs MCP handler +- ❌ PptDaemon.cs CLI handler (MISSED!) - ❌ FEATURES.md count (MISSED!) - ❌ README files (MISSED!) @@ -878,24 +878,24 @@ Result: Caught during commit review, required additional fixes. ## Rule 25: Use PowerShell Syntax in Documentation (CRITICAL) -**ExcelMcp is Windows-only. ALL documentation code blocks MUST use PowerShell syntax, NOT bash.** +**PptMcp is Windows-only. ALL documentation code blocks MUST use PowerShell syntax, NOT bash.** ```markdown # ❌ WRONG: bash syntax ```bash dotnet build -excelcli sheet list --file "test.xlsx" +pptcli sheet list --file "test.pptx" ``` # ✅ CORRECT: PowerShell syntax ```powershell dotnet build -excelcli sheet list --file "test.xlsx" +pptcli sheet list --file "test.pptx" ``` ``` **Why Critical:** -- ExcelMcp requires Windows + Excel COM interop +- PptMcp requires Windows + PowerPoint COM interop - bash syntax confuses Windows users - PowerShell is the native Windows shell - Syntax highlighting differs between bash/powershell @@ -970,35 +970,35 @@ Select-String -Path "**/*.md" -Pattern '```bash' -Recurse > **If the COM method's parameter name is clear and self-describing in our flat tool schema, use it. > If the COM name is opaque or ambiguous without its parent context, keep a more descriptive name.** -**Why:** MCP tool parameters appear in a flat schema — they lose the context of the parent class/method. A name that works when you see `PivotTable.RowAxisLayout(RowLayout)` may be opaque when the LLM just sees a `row_layout` parameter. Conversely, inventing a name like `layoutType` when COM already calls it `RowLayout` adds unnecessary indirection. +**Why:** MCP tool parameters appear in a flat schema — they lose the context of the parent class/method. A name that works when you see `Shape.Rotation` may be opaque when the LLM just sees a `rotation` parameter. Conversely, inventing a name like `rotationAngle` when COM already calls it `Rotation` adds unnecessary indirection. **Decision Framework:** | COM API | COM Param | Our Param | Rationale | |---------|-----------|-----------|-----------| -| `Names.Add(Name)` | `Name` | `name` | ✅ Clear in flat schema — "name of the named range" | -| `PivotTable.RowAxisLayout(RowLayout)` | `RowLayout` | `rowLayout` | ✅ `row_layout` values 0/1/2 are self-describing in tool schema | -| `Range.Value2` | (property) | `value` | ✅ Clear in context | -| `Workbook.Connections` | (collection) | `connectionName` | ✅ Keep descriptive — COM's `Name` property is too generic | -| `PivotField.Subtotals` | (property) | `subtotalFunction` | ✅ Keep descriptive — `subtotals` alone is ambiguous | +| `Slides.Add(Index, Layout)` | `Index` | `slideIndex` | ✅ Keep descriptive — `index` alone is ambiguous | +| `Shape.Rotation` | `Rotation` | `rotation` | ✅ `rotation` is self-describing in tool schema | +| `Shape.Name` | `Name` | `shapeName` | ✅ Keep descriptive — COM's `Name` property is too generic in flat schema | +| `TextFrame.Text` | (property) | `text` | ✅ Clear in context | +| `SlideRange.Item(Index)` | `Index` | `slideIndex` | ✅ Keep descriptive — `index` alone is ambiguous | **Implementation Pattern:** ```csharp // ✅ COM name is clear → use it directly -void Write(IExcelBatch batch, [FromString("name")] string name, ...); +void SetRotation(IPptBatch batch, string shapeName, float rotation, ...); // ✅ COM name works in flat schema → use it -OperationResult SetLayout(IExcelBatch batch, string pivotTableName, int rowLayout); +OperationResult SetText(IPptBatch batch, string shapeName, string text); // ✅ COM name too generic → keep descriptive -void AddField(IExcelBatch batch, string pivotTableName, string fieldName, string fieldArea); +void AddShape(IPptBatch batch, int slideIndex, string shapeName, string shapeType); ``` **When Adding New Parameters:** 1. Check the COM API docs for the original parameter name 2. Ask: "Would an LLM understand `{com_param_name}` without seeing the method/class name?" -3. If YES → use COM name (e.g., `name`, `rowLayout`, `reference`) -4. If NO → use descriptive name (e.g., `fieldName` not `Name`, `subtotalFunction` not `Function`) +3. If YES → use COM name (e.g., `rotation`, `text`, `left`) +4. If NO → use descriptive name (e.g., `shapeName` not `Name`, `slideIndex` not `Index`) --- @@ -1077,9 +1077,9 @@ public void ProgressAdapter_Maps_Current_To_Progress() ## Rule 30: Integration Tests Over Unit Tests (CRITICAL) -**NEVER write unit tests. Unit tests that mock COM objects, fake contexts, or test adapter mappings in isolation prove NOTHING. Write integration tests that exercise real Excel COM automation.** +**NEVER write unit tests. Unit tests that mock COM objects, fake contexts, or test adapter mappings in isolation prove NOTHING. Write integration tests that exercise real PowerPoint COM automation.** -**Why Critical:** ExcelMcp is a COM interop project. The bugs that matter — STA threading deadlocks, COM object leaks, OleMessageFilter re-entrancy, type conversion failures (`double` vs `int`), QueryTable persistence — **only manifest when real Excel is running**. A unit test that verifies an adapter maps field A to field B catches zero real bugs. An integration test that opens a workbook, refreshes a Power Query, and verifies the result catches ALL of them. +**Why Critical:** PptMcp is a COM interop project. The bugs that matter — STA threading deadlocks, COM object leaks, OleMessageFilter re-entrancy, type conversion failures (`double` vs `int`), shape persistence — **only manifest when real PowerPoint is running**. A unit test that verifies an adapter maps field A to field B catches zero real bugs. An integration test that opens a presentation, adds a shape, and verifies the result catches ALL of them. ```csharp // ❌ WRONG: Unit test that proves nothing @@ -1095,35 +1095,35 @@ public void Adapter_Maps_Field_A_To_Field_B() // ✅ CORRECT: Integration test that catches real bugs [Fact] [Trait("Category", "Integration")] -[Trait("Feature", "PowerQuery")] -public void Refresh_ReportsProgress_DuringExecution() +[Trait("Feature", "Slide")] +public void AddShape_ReportsProgress_DuringExecution() { - using var batch = ExcelSession.BeginBatch(_testFile); + using var batch = PptSession.BeginBatch(_testFile); var progress = new List(); - var result = _commands.Refresh(batch, "TestQuery", + var result = _commands.AddShape(batch, 1, "Rectangle", new Progress(p => progress.Add(p))); Assert.True(result.Success); - Assert.NotEmpty(progress); // Real Excel, real refresh, real progress + Assert.NotEmpty(progress); // Real PowerPoint, real shape creation, real progress } ``` **What Counts as Integration:** -- ✅ Opens a real Excel workbook via COM +- ✅ Opens a real PowerPoint presentation via COM - ✅ Exercises real batch.Execute() on STA thread - ✅ Verifies real data flows through the full pipeline - ✅ Catches COM threading, type conversion, and persistence bugs **What Does NOT Count:** -- ❌ Mocking IProgress, IExcelBatch, or any COM interface +- ❌ Mocking IProgress, IPptBatch, or any COM interface - ❌ Testing adapter/mapper classes in isolation - ❌ Verifying AsyncLocal behavior without COM context -- ❌ Any test that passes without Excel.exe running +- ❌ Any test that passes without PowerPoint.exe running **Enforcement:** - Code review MUST reject unit tests for COM-dependent features - All new tests MUST have `[Trait("Category", "Integration")]` -- If a test doesn't require Excel, question whether it tests anything meaningful +- If a test doesn't require PowerPoint, question whether it tests anything meaningful - The only acceptable non-integration tests are for pure algorithmic utilities with zero COM dependency (e.g., string parsing, enum mapping validation) -**Historical Lesson:** 10 unit tests were written for the MCP progress feature (McpProgressAdapter mapping, ProgressContext AsyncLocal). All 10 passed. Zero of them would have caught the real bugs: STA thread affinity issues, COM callback re-entrancy during refresh, or progress notifications not flowing through the generated code pipeline. The unit tests tested the unit tests. +**Historical Lesson:** 10 unit tests were written for the MCP progress feature (McpProgressAdapter mapping, ProgressContext AsyncLocal). All 10 passed. Zero of them would have caught the real bugs: STA thread affinity issues, COM callback re-entrancy during shape operations, or progress notifications not flowing through the generated code pipeline. The unit tests tested the unit tests. diff --git a/.github/instructions/development-workflow.instructions.md b/.github/instructions/development-workflow.instructions.md index 065b2693..ca209fea 100644 --- a/.github/instructions/development-workflow.instructions.md +++ b/.github/instructions/development-workflow.instructions.md @@ -29,10 +29,10 @@ Enforced: PR reviews, CI/CD checks, create a branch first, up-to-date branches, # ⚠️ IMPORTANT: gh CLI requires authentication with a PERSONAL GitHub account. # Enterprise Managed User (EMU) accounts cannot access public repos via gh CLI. # Use: gh auth login --with-token (with a personal access token) -gh api repos/sbroenne/mcp-server-excel/pulls/PULL_NUMBER/comments --paginate +gh api repos/trsdn/mcp-server-ppt/pulls/PULL_NUMBER/comments --paginate # Or use the mcp_github tool if available -mcp_github_github_pull_request_read(method="get_review_comments", owner="sbroenne", repo="mcp-server-excel", pullNumber=PULL_NUMBER) +mcp_github_github_pull_request_read(method="get_review_comments", owner="trsdn", repo="mcp-server-ppt", pullNumber=PULL_NUMBER) ``` **Common automated reviewers:** @@ -68,7 +68,7 @@ Quick reference: - `dependency-review.yml` - Dependency security scanning **Disabled Workflows:** -- `integration-tests.yml.disabled` - Excel COM integration tests (Azure runner undeployed) +- `integration-tests.yml.disabled` - PowerPoint COM integration tests (Azure runner undeployed) - `deploy-azure-runner.yml.disabled` - Azure runner deployment (infrastructure removed) **Note:** Integration tests are currently disabled. The Azure self-hosted runner has been undeployed. To re-enable, see `docs/AZURE_SELFHOSTED_RUNNER_SETUP.md`. diff --git a/.github/instructions/excel-com-interop.instructions.md b/.github/instructions/excel-com-interop.instructions.md deleted file mode 100644 index f4addb66..00000000 --- a/.github/instructions/excel-com-interop.instructions.md +++ /dev/null @@ -1,386 +0,0 @@ ---- -applyTo: "src/ExcelMcp.Core/**/*.cs" ---- - -# Excel COM Interop Patterns - -> **Essential patterns for Excel COM automation** - -## Core Principles - -1. **Use Late Binding** - `dynamic` types with `Type.GetTypeFromProgID()` -2. **1-Based Indexing** - Excel collections start at 1, not 0 -3. **Exception Propagation** - Never wrap in try-catch, let batch.Execute() handle exceptions (see Exception Propagation section) -4. **QueryTable Refresh REQUIRED** - `.Refresh(false)` synchronous for persistence -5. **NEVER use RefreshAll()** - Async/unreliable; use individual `connection.Refresh()` or `queryTable.Refresh(false)` - -## Reference Resources - -**NetOffice Library** - THE BEST source for ALL Excel COM Interop patterns: -- GitHub: https://github.com/NetOfficeFw/NetOffice -- **Use for ALL COM Interop work** - ranges, worksheets, workbooks, charts, PivotTables, Power Query, VBA, connections, everything -- NetOffice wraps Office COM APIs in strongly-typed C# - study their patterns for dynamic interop conversion -- Search NetOffice repository BEFORE implementing any Excel COM automation -- Particularly valuable for: PivotTables, OLAP CubeFields, Data Model operations, QueryTables, complex COM scenarios - -## Exception Propagation Pattern (CRITICAL) - -**Core Commands: NEVER wrap operations in try-catch blocks that return error results. Let exceptions propagate naturally.** - -```csharp -// ❌ WRONG: Catching and wrapping exceptions -public async Task CreateAsync(IExcelBatch batch, string name) -{ - try - { - return await batch.Execute((ctx, ct) => { - var item = ctx.Create(name); - return ValueTask.FromResult(new OperationResult { Success = true }); - }); - } - catch (Exception ex) - { - // ❌ WRONG: Double-wrapping suppresses the exception - return new OperationResult { Success = false, ErrorMessage = ex.Message }; - } -} - -// ✅ CORRECT: Let batch.Execute() handle exceptions via TaskCompletionSource -public async Task CreateAsync(IExcelBatch batch, string name) -{ - return await batch.Execute((ctx, ct) => { - var item = ctx.Create(name); - return ValueTask.FromResult(new OperationResult { Success = true }); - }); - // Exception flows to batch.Execute() → caught via TaskCompletionSource - // → Returns OperationResult { Success = false, ErrorMessage } -} - -// ✅ CORRECT: Finally blocks are the right place for COM resource cleanup -public async Task ComplexAsync(IExcelBatch batch, string name) -{ - dynamic? temp = null; - try - { - return await batch.Execute((ctx, ct) => { - temp = ctx.CreateTemp(name); - // ... operation ... - return ValueTask.FromResult(new OperationResult { Success = true }); - }); - } - finally - { - // ✅ Finally for resource cleanup, NOT catch for error handling - if (temp != null) - { - ComUtilities.Release(ref temp!); - } - } -} -``` - -**Why This Pattern:** -- `batch.Execute()` ALREADY captures exceptions via `TaskCompletionSource` -- Inner try-catch suppresses exceptions, causing double-wrapping and lost stack context -- Finally blocks work perfectly for COM resource cleanup (which must happen regardless of exception) -- Exception occurs at correct layer (batch), not suppressed at method level - -**Safe Exception Handling (Keep these):** -- ✅ Loop continuations: `catch { continue; }` (safe, recovers loop) -- ✅ Optional property access: `catch { value = null; }` (safe, uses fallback) -- ✅ Specific error routing: `catch (COMException ex) when (ex.HResult == code) { ... }` (specific, not general) -- ✅ Finally blocks: Resource cleanup for COM objects (always needed) - -**Pattern to Remove:** -- ❌ `catch (Exception ex) { return new Result { Success = false, ErrorMessage = ex.Message }; }` - -**Architecture:** -``` -Core Command (NO try-catch wrapping) - └─> await batch.Execute() - └─> TaskCompletionSource captures exception - └─> Returns OperationResult { Success = false, ErrorMessage } -``` - ---- - -## Resource Management - -### ✅ Unified Shutdown Pattern (Current Standard) - -**All workbook close and Excel quit operations use `ExcelShutdownService` with resilient retry:** - -```csharp -// In ExcelBatch, ExcelSession, FileCommands: -ExcelShutdownService.CloseAndQuit(workbook, excel, save: false, filePath, logger); -``` - -**Shutdown Order:** -1. **Optional Save** - If `save=true`, calls `workbook.Save()` explicitly before close -2. **Close Workbook** - Calls `workbook.Close(save)` (save param controls Excel's prompt behavior) -3. **Release Workbook** - Releases COM reference via `ComUtilities.Release()` -4. **Quit Excel** - Calls `excel.Quit()` with exponential backoff retry (6 attempts, 200ms base delay) -5. **Release Excel** - Releases COM reference via `ComUtilities.Release()` -6. **Automatic GC** - RCW finalizers handle final cleanup automatically (no forced GC needed per Microsoft guidance) - -**Resilience Features:** -- Uses `Microsoft.Extensions.Resilience` retry pipeline -- **Outer timeout (30s)**: Overall cancellation for Excel.Quit() - catches hung Excel (modal dialogs, deadlocks) -- **Inner retry**: Exponential backoff (200ms base, 2x factor, 6 attempts) for transient COM busy errors -- Retries on: `RPC_E_SERVERCALL_RETRYLATER` (-2147417851), `RPC_E_CALL_REJECTED` (-2147418111) -- Structured logging for diagnostics (attempt number, HResult, elapsed time) -- Continues with COM cleanup even if Quit fails/times out -- **STA thread join (45s)**: Must be >= ExcelQuitTimeout + margin (currently 30s + 15s) to ensure Dispose() waits for full cleanup - -**Save Semantics:** -```csharp -// Discard changes (default for disposal paths) -ExcelShutdownService.CloseAndQuit(workbook, excel, save: false, filePath, logger); - -// Save before close (for explicit save operations) -ExcelShutdownService.CloseAndQuit(workbook, excel, save: true, filePath, logger); -``` - -**Why Unified Service:** -- Eliminates duplicated try/catch blocks across `ExcelBatch`, `ExcelSession`, `FileCommands` -- Consistent retry behavior for all Excel quit operations -- Centralized logging and diagnostics -- Handles edge cases: disconnected COM proxies, hung Excel, modal dialogs - -**Timeout Architecture (Proper Layering):** -``` -Overall Quit Timeout: 30 seconds (outer) - └─> Resilient Retry: 6 attempts with exponential backoff (inner, ~6s max) - └─> Individual Quit() calls - └─> STA Thread Join: 45 seconds (ExcelQuitTimeout + 15s margin) -``` -- **30s quit timeout**: Catches truly hung Excel (modal dialogs, deadlocks) via CancellationToken -- **6-attempt retry**: Handles transient COM busy states within the 30s window -- **45s thread join**: Must be >= ExcelQuitTimeout + margin to ensure Dispose() waits for full cleanup - -## COM Object Cleanup Pattern (CRITICAL) - -**ALWAYS use try-finally for COM object cleanup. NEVER use catch blocks to swallow exceptions.** - -### ❌ WRONG Patterns - -```csharp -// WRONG #1: COM cleanup in try block (won't execute if exception occurs) -try -{ - dynamic pivotLayout = chart.PivotLayout; - dynamic pivotTable = pivotLayout.PivotTable; - name = pivotTable.Name?.ToString() ?? string.Empty; - ComUtilities.Release(ref pivotTable!); // ❌ Won't execute if exception above! - ComUtilities.Release(ref pivotLayout!); -} -catch -{ - name = "(unknown)"; // ❌ Swallows exception, causes COM leak -} - -// WRONG #2: Empty catch block (swallows exceptions silently) -try -{ - dynamic item = GetItem(); - // ... operations ... - ComUtilities.Release(ref item!); -} -catch -{ - // ❌ Empty catch - swallows exception, no cleanup -} -``` - -### ✅ CORRECT Pattern - -```csharp -// CORRECT: Finally block ensures cleanup regardless of exceptions -dynamic? pivotLayout = null; -dynamic? pivotTable = null; -try -{ - pivotLayout = chart.PivotLayout; - pivotTable = pivotLayout.PivotTable; - name = pivotTable.Name?.ToString() ?? string.Empty; -} -finally -{ - // ✅ ALWAYS executes - exception or no exception - if (pivotTable != null) ComUtilities.Release(ref pivotTable!); - if (pivotLayout != null) ComUtilities.Release(ref pivotLayout!); -} -// ✅ Exception propagates naturally to batch.Execute() -``` - -**Pattern Requirements:** -1. **Declare COM objects as `dynamic?` nullable** before try block -2. **Initialize to `null`** -3. **Acquire COM objects in try block** -4. **Release in finally block** with null checks -5. **NO catch blocks** unless specific exception handling required -6. **NEVER catch to set fallback values** - let exceptions propagate - -**Why This Matters:** -- Finally blocks execute **regardless** of exceptions (try succeeds or fails) -- COM objects leak if Release() not reached before exception -- Swallowing exceptions with catch blocks hides real problems from batch.Execute() -- Empty catch blocks are code smell - remove them entirely -- Let exceptions propagate naturally to batch.Execute() for proper error handling - -**See Also:** -- CRITICAL-RULES.md Rule 22 for complete requirements -- CRITICAL-RULES.md Rule 1b for exception propagation pattern - -## Critical COM Issues - -### 1. Excel Collections Are 1-Based -```csharp -// ❌ WRONG: collection.Item(0) -// ✅ CORRECT: collection.Item(1) -for (int i = 1; i <= collection.Count; i++) { var item = collection.Item(i); } -``` - -### 2. Named Range Format -```csharp -// ❌ WRONG: namesCollection.Add("Param", "Sheet1!A1"); // Missing = -// ✅ CORRECT: namesCollection.Add("Param", "=Sheet1!A1"); -string ref = reference.StartsWith("=") ? reference : $"={reference}"; -``` - -### 3. Power Query Loading -```csharp -// ❌ WRONG: listObjects.Add(...) // Causes "Value does not fall within expected range" -// ✅ CORRECT: Use QueryTables with synchronous refresh -string cs = $"OLEDB;Provider=Microsoft.Mashup.OleDb.1;Data Source=$Workbook$;Location={queryName}"; -dynamic qt = sheet.QueryTables.Add(cs, sheet.Range["A1"], commandText); -qt.Refresh(false); // CRITICAL: false = synchronous, ensures persistence -``` - -### 4. QueryTable Persistence Pattern - -**⚠️ RefreshAll() does NOT persist QueryTables!** - -```csharp -// ❌ WRONG: workbook.RefreshAll(); workbook.Save(); // QueryTable lost on reopen -// ✅ CORRECT: queryTable.Refresh(false); workbook.Save(); // Persists properly -``` - -**Why:** `RefreshAll()` is async. Individual `qt.Refresh(false)` is synchronous and required for disk persistence. - -### 5. Numeric Property Type Conversions - -**⚠️ ALL Excel COM numeric properties return `double`, NOT `int`!** - -```csharp -// ❌ WRONG: Implicit conversion fails at runtime -int orientation = field.Orientation; // Runtime error: Cannot convert double to int -int position = field.Position; // Runtime error: Cannot convert double to int -int function = field.Function; // Runtime error: Cannot convert double to int - -// ✅ CORRECT: Explicit conversion required -int orientation = Convert.ToInt32(field.Orientation); -int position = Convert.ToInt32(field.Position); -int comFunction = Convert.ToInt32(field.Function); -``` - -**Common Properties Affected:** -- `PivotField.Orientation` → `double` (not `XlPivotFieldOrientation` enum) -- `PivotField.Position` → `double` (not `int`) -- `PivotField.Function` → `double` (not `XlConsolidationFunction` enum) -- `Range.Row`, `Range.Column` → `double` (not `int`) -- Any numeric property from Excel COM → assume `double` - -**Date Properties:** -```csharp -// RefreshDate can be DateTime OR double (OLE date) -private static DateTime? GetRefreshDateSafe(dynamic refreshDate) -{ - if (refreshDate == null) return null; - if (refreshDate is DateTime dt) return dt; - if (refreshDate is double dbl) return DateTime.FromOADate(dbl); - return null; -} -``` - -**Why:** Excel COM uses `VARIANT` types internally, which represent numbers as `double`. C# `dynamic` binding preserves this type. - -### 6. Excel Busy Handling -```csharp -catch (COMException ex) when (ex.HResult == -2147417851) -{ - // RPC_E_SERVERCALL_RETRYLATER - Excel is busy -} -``` - -## Common Patterns - -### Read Data -```csharp -dynamic range = sheet.Range["A1:D10"]; -object[,] values = range.Value2; // 2D array, 1-based indexing -``` - -### Write Data -```csharp -object[,] data = new object[rows, cols]; -dynamic range = sheet.Range[startCell, endCell]; -range.Value2 = data; // Bulk write -``` - -### Refresh Query -```csharp -// ❌ NEVER: workbook.RefreshAll(); // Hangs! -// ✅ CORRECT: targetConnection.Refresh(); -``` - -## Connection Type Discrepancy - -**⚠️ Excel COM runtime types don't match spec!** -```csharp -if (connType == 3 || connType == 4) { // TEXT files report as type 4 (WEB) - try { var conn = connection.TextConnection; } - catch { var conn = connection.WebConnection; } -} -``` - -## Data Model (Power Pivot) API Limitations - -**⚠️ KNOWN LIMITATION: Hidden columns, relationships, and measures cannot be detected via Excel COM API** - -When objects are marked "Hidden from client tools" in Power Pivot, the Excel COM API provides no way to detect this or retrieve them. - -**Affected Objects:** - -| Object | Available Properties | Missing | -|--------|---------------------|---------| -| `ModelTableColumn` | Application, Creator, DataType, Name, Parent | **NO IsHidden** | -| `ModelRelationship` | Application, Creator, ForeignKeyColumn, ForeignKeyTable, PrimaryKeyColumn, PrimaryKeyTable, Active | **NO IsHidden** | -| `ModelMeasure` | Application, AssociatedTable, Creator, Description, FormatInformation, Formula, Name, Parent | **NO IsHidden** | - -**Alternative APIs that were investigated and DO NOT WORK:** - -| Approach | Why It Doesn't Work | -|----------|---------------------| -| TOM (Tabular Object Model) | Requires `Microsoft.AnalysisServices.Tabular` library which cannot connect to Excel's embedded Analysis Services engine | -| XMLA queries | Excel's embedded AS engine doesn't expose a queryable endpoint for external XMLA connections | -| CubeField.ShowInFieldList | Only applies to PivotTable field visibility, not underlying Data Model hidden status | - -**Bottom Line:** If a column, relationship, or measure is hidden in the Data Model, it cannot be seen or listed through the Excel COM API. This is a fundamental limitation of Microsoft's Excel automation interface. - ---- - -## Common Mistakes - -| Mistake | Fix | -|---------|-----| -| 0-based indexing | Excel is 1-based | -| `RefreshAll()` | Use individual refresh | -| Missing `=` in ranges | Always prefix with `=` | -| `ListObjects.Add()` for PQ | Use `QueryTables.Add()` | -| Not releasing objects | `try/finally` + `ReleaseComObject()` | -| `int x = field.Property` | Use `Convert.ToInt32()` for ALL numeric properties | -| Assuming enum types | Numeric properties return `double`, convert to enum | -| Using TOM/XMLA for Data Model | Not accessible from Excel COM - use only ModelTable/ModelTableColumn APIs | - -**📚 Reference:** [Excel Object Model](https://docs.microsoft.com/en-us/office/vba/api/overview/excel) diff --git a/.github/instructions/excel-connection-types-guide.instructions.md b/.github/instructions/excel-connection-types-guide.instructions.md deleted file mode 100644 index e13900a8..00000000 --- a/.github/instructions/excel-connection-types-guide.instructions.md +++ /dev/null @@ -1,130 +0,0 @@ ---- -applyTo: "src/ExcelMcp.Core/Commands/ConnectionCommands.cs,src/ExcelMcp.Core/Connections/**/*.cs,tests/**/ConnectionCommands*.cs,tests/**/ConnectionTestHelper.cs" ---- - -# Excel Connection Types - LLM Quick Reference - -> **What works, what doesn't, and what to do instead** - -## CRITICAL: LoadTo Operation Limitations - -**LoadTo action only works with OLEDB/ODBC connections:** - -| Connection Type | LoadTo Support | What to Use Instead | -|----------------|----------------|---------------------| -| OLEDB | Works | Primary use case | -| ODBC | Works | Primary use case | -| TEXT | FAILS | Use powerquery create + refresh | -| WEB | FAILS | Use powerquery create + refresh | -| Power Query | Works | Use powerquery refresh | - -**Error pattern:** If LoadTo returns "Value does not fall within the expected range" then connection type doesn't support QueryTable pattern - use Power Query instead. - -## Connection Action Compatibility - -| Action | OLEDB/ODBC | TEXT | WEB | Power Query | -|--------|-----------|------|-----|-------------| -| List | Works | Works | Works | Works | -| View | Works | Works | Works | Works | -| Create | Works | Works | Works | Use powerquery | -| Delete | Works | Works | Works | Use powerquery | -| LoadTo | Works | FAILS | FAILS | Use powerquery refresh | -| Refresh | Works | Works* | Works* | Use powerquery refresh | -| Test | Works | Works | Works | Works | - -*TEXT/WEB Refresh succeeds but doesn't validate data source existence until actual data access - -## Decision Tree: Connection vs Power Query - -``` -Need to import data from file/URL? -├─ OLEDB/ODBC data source? -│ └─ Use connection (LoadTo, Refresh) -│ -├─ TEXT file (CSV, TXT)? -│ └─ Use powerquery (create with M code, refresh) -│ -├─ Web API/URL? -│ └─ Use powerquery (create with M code, refresh) -│ -└─ Already has Power Query? - └─ Use powerquery (refresh) -``` - -## Recommended Workflows - -**OLEDB/ODBC Data Loading:** -``` -1. connection create → Creates connection object -2. connection loadto → Loads data to worksheet -3. connection refresh → Updates data from source -``` - -**TEXT/CSV File Import:** -``` -1. powerquery create → Import CSV with M code -2. powerquery refresh → Reload data - (Don't use connection loadto - will fail!) -``` - -**Web Data Import:** -``` -1. powerquery create → Import from URL with M code -2. powerquery refresh → Update data - (Don't use connection loadto - will fail!) -``` - -## Common Mistakes to Avoid - -1. **Using LoadTo with TEXT connections** - Will fail with E_INVALIDARG - Use Power Query instead -2. **Using LoadTo with WEB connections** - Will fail - Use Power Query instead -3. **Assuming Refresh validates TEXT file existence** - Excel doesn't check until data access -4. **Mixing connection and Power Query operations** - Power Query connections need powerquery tool - -## Connection String Examples - -``` -OLEDB: "Provider=SQLOLEDB;Data Source=server;Initial Catalog=db;..." -ODBC: "DSN=MyDataSource;UID=username;PWD=password;..." -TEXT: "TEXT;C:\\path\\to\\file.csv" -WEB: "URL;https://example.com/data.xml" -``` - -## Security - -**Always sanitize connection strings before displaying** - Never expose passwords or sensitive credentials in error messages or logs. - ---- - -## Developer Reference (Implementation Details) - -
-Click to expand developer implementation notes - -### Implementation Notes - -**Connections.Add2() method required for OLEDB/ODBC:** - -Use the COM Add2 method with parameters: Name, Description, ConnectionString, CommandText (empty), lCmdtype (auto-detect), CreateModelConnection (false), ImportRelationships (false). - -### Type 3/4 Ambiguity - -TEXT connections created with "TEXT;path" may return type 4 (WEB) instead of 3 (TEXT) - handle both types interchangeably in type detection logic. - -### Test Strategy - -- **OLEDB** - Use for LoadTo, Refresh, and QueryTable operation tests -- **TEXT** - Use for connection lifecycle tests (List, View, Delete) without LoadTo -- **ODBC** - Use for validation of multiple connection types - -### Connection String Internal Formats - -``` -OLEDB: "Provider=SQLOLEDB;Data Source=server;Initial Catalog=db;..." -ODBC: "DSN=MyDataSource;UID=username;PWD=password;..." -TEXT: "TEXT;C:\\path\\to\\file.csv" -WEB: "URL;https://example.com/data.xml" -Power Query: "OLEDB;Provider=Microsoft.Mashup.OleDb.1;Data Source=$Workbook$;Location=QueryName" -``` - -
diff --git a/.github/instructions/llm-testing-philosophy.instructions.md b/.github/instructions/llm-testing-philosophy.instructions.md index 3cf797c3..4539edbb 100644 --- a/.github/instructions/llm-testing-philosophy.instructions.md +++ b/.github/instructions/llm-testing-philosophy.instructions.md @@ -50,15 +50,15 @@ A real user doesn't know our CLI syntax. Neither should the test prompt. ```python # ❌ WRONG: Teaching the LLM how to use our CLI prompt = """ -Create a PivotTable then set layout to Compact using -'excelcli pivottablecalc' (run --help to see options). -The compact layout uses row-layout value 0. +Create a slide layout then set it to use a custom master using +'pptcli slidemaster' (run --help to see options). +The custom layout uses layout-type value 0. """ # ✅ CORRECT: Natural user request prompt = """ -Create a PivotTable with Compact layout showing -Department and Team as rows, Hours as values. +Create a presentation with a custom slide layout showing +title and content areas. """ ``` @@ -67,7 +67,7 @@ Department and Team as rows, Hours as values. ```python # ❌ WRONG: Directing the LLM to a specific command prompt = """ -Use the 'excelcli chartconfig' command to change the chart title. +Use the 'pptcli chartconfig' command to change the chart title. """ # ✅ CORRECT: What the user wants @@ -85,14 +85,14 @@ The system prompt belongs to the agent, not to us. We don't control what system # ❌ WRONG: Adding our own guidance to the system prompt agent = Agent( system_prompt=( - "Run 'excelcli --help' when unsure about parameter names\n" + "Run 'pptcli --help' when unsure about parameter names\n" "Always use -q flag for clean JSON output" ), ) # ✅ CORRECT: No system prompt (use skill only) or minimal role context agent = Agent( - skill=excel_cli_skill, # Our product — this IS the right place for guidance + skill=ppt_cli_skill, # Our product — this IS the right place for guidance ) ``` @@ -101,12 +101,12 @@ agent = Agent( ```python # ❌ WRONG: Teaching the LLM our session model prompt = """ -IMPORTANT: First, run 'excelcli session list' to confirm +IMPORTANT: First, run 'pptcli session list' to confirm which file is open, then use that file path. """ # ✅ CORRECT: If session discovery is hard, fix the product: -# - Better error messages: "No active session. Run 'excelcli session list' to see open files." +# - Better error messages: "No active session. Run 'pptcli session list' to see open files." # - Better skill docs: Add "Session Management" section with recovery patterns # - Better --help: Show session workflow in command help ``` @@ -115,20 +115,22 @@ which file is open, then use that file path. ### Natural Language Prompts -Write prompts as a knowledgeable Excel user would. They know Excel concepts but NOT our specific CLI/MCP tool syntax. +Write prompts as a knowledgeable PowerPoint user would. They know PowerPoint concepts but NOT our specific CLI/MCP tool syntax. ```python prompt = f""" -Create a new Excel file at {unique_path('sales-analysis')} +Create a new PowerPoint file at {unique_path('sales-presentation')} -Enter this sales data: +Add a title slide with: +Title: "Q1 Sales Report" +Subtitle: "Regional Performance Summary" + +Add a second slide with a table: Region, Product, Sales North, Widget, 15000 South, Gadget, 12000 -Create a PivotTable showing Region as rows and Sum of Sales as values. -Add a slicer for the Region field. -Filter to show only North region. +Add a chart on the third slide showing Region vs Sales. Save and close the file. """ @@ -144,7 +146,7 @@ assert result.success assert_cli_exit_codes(result) # ✅ Good: Did the LLM report key results? -assert_regex(result.final_response, r"(?i)(pivot|region|north)") +assert_regex(result.final_response, r"(?i)(slide|shape|title)") # ⚠️ Fragile: Exact numeric values across 5 conversation turns assert_regex(result.final_response, r"\$?43,500\.00") # Requires perfect execution of ALL prior steps @@ -156,19 +158,19 @@ Match test complexity to what a real user would attempt in one conversation: | Complexity | Turns | Example | |-----------|-------|---------| -| Simple | 1 | Create file → write data → read back → close | -| Medium | 1-2 | Create file → build table → add chart → save | -| Complex | 2-3 | Build data model → create measures → analyze | +| Simple | 1 | Create file → add slides → read back → close | +| Medium | 1-2 | Create file → add content → add chart → save | +| Complex | 2-3 | Build presentation → add animations → configure transitions | | Unreasonable | 5+ | 13-step workflow with exact numeric assertions on final state | ## Where to Fix Failures When a test fails, investigate in this order: -### 1. Skill Documentation (`skills/excel-cli/SKILL.md`, `skills/excel-mcp/SKILL.md`) +### 1. Skill Documentation (`skills/ppt-cli/SKILL.md`, `skills/ppt-mcp/SKILL.md`) The skill IS our product's interface to LLMs. If the LLM doesn't know how to: -- Set a PivotTable layout → Add workflow patterns to the skill +- Add a shape to a slide → Add workflow patterns to the skill - Use `--values-file` instead of `--values` → Document when to use file params - Discover `chartconfig` commands → Add chart modification patterns @@ -181,7 +183,7 @@ To change chart properties WITHOUT deleting the chart: ### 2. CLI `--help` Output -The `--help` text is what the LLM sees when it runs `excelcli --help`. If a parameter is hard to discover, improve the help text. +The `--help` text is what the LLM sees when it runs `pptcli --help`. If a parameter is hard to discover, improve the help text. Look at: - `[Description]` attributes on Settings properties @@ -218,10 +220,10 @@ If the LLM consistently gets a parameter name wrong, the name might be confusing agent = Agent( name="descriptive-test-name", provider=Provider(model=f"azure/{DEFAULT_MODEL}", rpm=DEFAULT_RPM, tpm=DEFAULT_TPM), - cli_servers=[excel_cli_server], # CLI tests + cli_servers=[ppt_cli_server], # CLI tests # OR - mcp_servers=[excel_mcp_server], # MCP tests - skill=excel_cli_skill, # Our product documentation + mcp_servers=[ppt_mcp_server], # MCP tests + skill=ppt_cli_skill, # Our product documentation max_turns=DEFAULT_MAX_TURNS, # Always set explicitly ) ``` @@ -240,7 +242,7 @@ result = await aitest_run(agent, "Create file and enter data...") messages = result.messages # Turn 2: Analyze (natural continuation) -result = await aitest_run(agent, "Now create a PivotTable from that data...", messages=messages) +result = await aitest_run(agent, "Now add a chart to the third slide from that data...", messages=messages) ``` Keep multi-turn tests to **2-3 turns maximum**. If you need 5 turns, the test is testing too many features at once — split it into separate tests. @@ -256,14 +258,14 @@ Keep multi-turn tests to **2-3 turns maximum**. If you need 5 turns, the test is | calculation_mode | `test_cli_calculation_mode.py` | `test_mcp_calculation_mode.py` | | chart | `test_cli_chart.py` | `test_mcp_chart.py` | | chart_positioning | `test_cli_chart_positioning.py` | `test_mcp_chart_positioning.py` | -| file_worksheet | `test_cli_file_worksheet.py` | `test_mcp_file_worksheet.py` | +| file_slide | `test_cli_file_slide.py` | `test_mcp_file_slide.py` | | financial_report_automation | `test_cli_financial_report_automation.py` | `test_mcp_financial_report_automation.py` | | modification_patterns | `test_cli_modification_patterns.py` | `test_mcp_modification_patterns.py` | -| pivottable_layout | `test_cli_pivottable_layout.py` | `test_mcp_pivottable_layout.py` | -| powerquery_datamodel | `test_cli_powerquery_datamodel.py` | `test_mcp_powerquery_datamodel.py` | +| slide_layout | `test_cli_slide_layout.py` | `test_mcp_slide_layout.py` | +| shape_operations | `test_cli_shape_operations.py` | `test_mcp_shape_operations.py` | | range | `test_cli_range.py` | `test_mcp_range.py` | | sales_report_workflow | `test_cli_sales_report_workflow.py` | `test_mcp_sales_report_workflow.py` | -| slicer | `test_cli_slicer.py` | `test_mcp_slicer.py` | +| animation | `test_cli_animation.py` | `test_mcp_animation.py` | | table | `test_cli_table.py` | `test_mcp_table.py` | ### Rules for Creating / Updating / Deleting Tests @@ -284,16 +286,16 @@ The ONLY differences between CLI and MCP versions of a test should be: # CLI version agent = Agent( name="test-name-cli", - cli_servers=[excel_cli_server], - skill=excel_cli_skill, + cli_servers=[ppt_cli_server], + skill=ppt_cli_skill, ... ) # MCP version agent = Agent( name="test-name-mcp", - mcp_servers=[excel_mcp_server], - skill=excel_mcp_skill, + mcp_servers=[ppt_mcp_server], + skill=ppt_mcp_skill, ... ) ``` @@ -321,7 +323,7 @@ C# Interfaces (XML /// docs) skills/shared/*.md (source of truth) → MSBuild EmbeddedResource with Link (embedded in assembly) → MSBuild GenerateSkillPromptsClass inline task - → ExcelSkillPrompts.g.cs (14 [McpServerPrompt] methods) + → PptSkillPrompts.g.cs (14 [McpServerPrompt] methods) → Claude Desktop sees identical guidance as skill clients ``` @@ -332,7 +334,7 @@ skills/shared/*.md (source of truth) | Wrong tool/command description | `I*Commands.cs` XML `/// ` | `SKILL.md` | | Wrong parameter docs | `I*Commands.cs` XML `/// ` | `SKILL.md` | | Wrong skill prose/rules/workflows | `skills/templates/SKILL.cli.sbn` or `SKILL.mcp.sbn` | `SKILL.md` | -| Wrong reference doc content | `skills/shared/*.md` | `skills/excel-*/references/*.md` | +| Wrong reference doc content | `skills/shared/*.md` | `skills/ppt-*/references/*.md` | | Wrong MCP prompt content | `skills/shared/*.md` | `Prompts/Content/Skills/` | | Wrong Tool Selection table (MCP) | `skills/templates/SKILL.mcp.sbn` | `SKILL.md` | | New skill reference needed | Add `.md` to `skills/shared/` + description in `.csproj` | Don't create separate prompt | @@ -341,8 +343,8 @@ skills/shared/*.md (source of truth) - **Templates:** `skills/templates/SKILL.cli.sbn`, `skills/templates/SKILL.mcp.sbn` - **Reference docs (source of truth):** `skills/shared/*.md` → auto-synced to BOTH skill refs AND MCP prompts -- **Generated files (NEVER edit):** `skills/excel-cli/SKILL.md`, `skills/excel-mcp/SKILL.md`, `obj/.../ExcelSkillPrompts.g.cs` -- **Description overrides:** `src/ExcelMcp.McpServer/ExcelMcp.McpServer.csproj` → `GenerateSkillPromptsClass` task +- **Generated files (NEVER edit):** `skills/ppt-cli/SKILL.md`, `skills/ppt-mcp/SKILL.md`, `obj/.../PptSkillPrompts.g.cs` +- **Description overrides:** `src/PptMcp.McpServer/PptMcp.McpServer.csproj` → `GenerateSkillPromptsClass` task - **Build command:** `dotnet build -c Release` regenerates SKILL.md, copies references, and generates prompt class ### Testing Impact diff --git a/.github/instructions/mcp-llm-guidance.instructions.md b/.github/instructions/mcp-llm-guidance.instructions.md index 846b93bd..4b6afef5 100644 --- a/.github/instructions/mcp-llm-guidance.instructions.md +++ b/.github/instructions/mcp-llm-guidance.instructions.md @@ -1,5 +1,5 @@ --- -applyTo: "src/ExcelMcp.McpServer/Prompts/**/*.md" +applyTo: "src/PptMcp.McpServer/Prompts/**/*.md" --- # MCP LLM Guidance Creation Guide @@ -10,7 +10,7 @@ applyTo: "src/ExcelMcp.McpServer/Prompts/**/*.md" **Write FOR expert LLMs (GitHub Copilot, Claude), not ABOUT the system.** -LLMs already know Excel, JSON, and MCP protocol. They need server-specific patterns only. +LLMs already know PowerPoint, JSON, and MCP protocol. They need server-specific patterns only. ## What to Include @@ -24,7 +24,7 @@ LLMs already know Excel, JSON, and MCP protocol. They need server-specific patte **3. Tool Selection:** - When to use this tool vs other tools -- Example: "Use range for data, worksheet for lifecycle" +- Example: "Use shape for content, slide for lifecycle" **4. Server-Specific Behavior:** - Quirks of THIS implementation @@ -42,7 +42,7 @@ LLMs already know Excel, JSON, and MCP protocol. They need server-specific patte ## What to Exclude **❌ DON'T explain:** -- Excel concepts (ranges, formulas, cells) +- PowerPoint concepts (slides, shapes, animations) - JSON syntax - Programming basics (arrays, null, types) - MCP protocol syntax @@ -76,14 +76,14 @@ LLMs already know Excel, JSON, and MCP protocol. They need server-specific patte - ✅ One markdown file per tool - ✅ 50-150 lines total per tool - ✅ Focus on disambiguation, not explanation -- ❌ Don't write Excel tutorials +- ❌ Don't write PowerPoint tutorials - ❌ Don't explain JSON syntax ## Format Guidelines **All MCP prompts are auto-generated from `skills/shared/*.md`:** - Source of truth: `skills/shared/*.md` — edit these files -- Auto-embedded and auto-generated `ExcelSkillPrompts.g.cs` at build time +- Auto-embedded and auto-generated `PptSkillPrompts.g.cs` at build time - NEVER create hand-crafted prompt files — add `.md` to `skills/shared/` instead - To add a new prompt: add `.md` to `skills/shared/`, add description override in `GenerateSkillPromptsClass` task in `McpServer.csproj`, rebuild @@ -117,7 +117,7 @@ LLMs already know Excel, JSON, and MCP protocol. They need server-specific patte - Already reusable between CLI and MCP Server **Implementation**: -- Location: `src/ExcelMcp.McpServer/Tools/*Tool.cs` +- Location: `src/PptMcp.McpServer/Tools/*Tool.cs` - Pattern: Ad-hoc JSON properties in tool responses **When to Add:** @@ -125,7 +125,7 @@ LLMs already know Excel, JSON, and MCP protocol. They need server-specific patte - After LIST operations: Suggest actions based on count - After UPDATE operations: Suggest verification - After FAILURE: Suggest troubleshooting -- Batch mode hints: "Creating multiple? Use begin_excel_batch" +- Batch mode hints: "Creating multiple? Use begin_ppt_batch" ## Success Criteria @@ -135,7 +135,7 @@ A good prompt: - ✅ Explains server-specific quirks - ✅ Helps choose between tools - ✅ Under 150 lines -- ❌ Doesn't teach Excel concepts +- ❌ Doesn't teach PowerPoint concepts - ❌ Doesn't show JSON syntax - ❌ Doesn't duplicate schema info diff --git a/.github/instructions/mcp-server-guide.instructions.md b/.github/instructions/mcp-server-guide.instructions.md index bf088345..b239f951 100644 --- a/.github/instructions/mcp-server-guide.instructions.md +++ b/.github/instructions/mcp-server-guide.instructions.md @@ -1,5 +1,5 @@ --- -applyTo: "src/ExcelMcp.McpServer/**/*.cs" +applyTo: "src/PptMcp.McpServer/**/*.cs" --- # MCP Server Development Guide @@ -23,7 +23,7 @@ applyTo: "src/ExcelMcp.McpServer/**/*.cs" ```csharp [McpServerTool] -public async Task ExcelPowerQuery(string action, string excelPath, ...) +public async Task PptSlide(string action, string pptPath, ...) { return action.ToLowerInvariant() switch { @@ -50,16 +50,16 @@ private static string ForwardSomeAction(string sessionId, string? param) if (string.IsNullOrEmpty(param)) throw new ModelContextProtocol.McpException("param is required for action"); - // 2. Forward to in-process ExcelMcpService (direct call, no pipe) - return ExcelToolsBase.ForwardToService("category.action", sessionId, new { param }); + // 2. Forward to in-process PptMcpService (direct call, no pipe) + return PptToolsBase.ForwardToService("category.action", sessionId, new { param }); } ``` **When to Throw McpException:** - ✅ **Parameter validation** - missing required params, invalid formats (pre-conditions) -- ✅ **File not found** - workbook doesn't exist (pre-conditions) +- ✅ **File not found** - presentation doesn't exist (pre-conditions) - ✅ **Batch not found** - invalid batch session (pre-conditions) -- ❌ **NOT for business logic errors** - table not found, query failed, connection error, etc. +- ❌ **NOT for business logic errors** - table not found, shape not found, operation failed, etc. **Why This Pattern:** - ✅ MCP spec requires business errors return JSON with `isError: true` flag @@ -71,7 +71,7 @@ private static string ForwardSomeAction(string sessionId, string? param) ```csharp // Core returns: { Success = false, ErrorMessage = "Table 'Sales' not found" } // MCP Tool: Return this as-is -return JsonSerializer.Serialize(result, ExcelToolsBase.JsonOptions); +return JsonSerializer.Serialize(result, PptToolsBase.JsonOptions); // Client receives via MCP protocol: // { // "jsonrpc": "2.0", @@ -99,7 +99,7 @@ if (string.IsNullOrWhiteSpace(tableName)) ```csharp [McpServerTool] -public static async Task ExcelTool(ToolAction action, ...) +public static async Task PptTool(ToolAction action, ...) { try { @@ -120,11 +120,11 @@ public static async Task ExcelTool(ToolAction action, ...) { Success = false, ErrorMessage = ex.Message, - FilePath = excelPath, + FilePath = pptPath, Action = action.ToActionString(), SuggestedNextActions = new List { - "Check if Excel is showing a dialog or prompt", + "Check if PowerPoint is showing a dialog or prompt", "Verify data source connectivity", "For large datasets, operation may need more time" }, @@ -138,11 +138,11 @@ public static async Task ExcelTool(ToolAction action, ...) ? "Maximum timeout reached. Check connectivity manually." : "Retry acceptable if issue is transient." }; - return JsonSerializer.Serialize(result, ExcelToolsBase.JsonOptions); + return JsonSerializer.Serialize(result, PptToolsBase.JsonOptions); } catch (Exception ex) { - ExcelToolsBase.ThrowInternalError(ex, action.ToActionString(), excelPath); + PptToolsBase.ThrowInternalError(ex, action.ToActionString(), pptPath); throw; // Unreachable but satisfies compiler } } @@ -155,7 +155,7 @@ public static async Task ExcelTool(ToolAction action, ...) ```csharp // Tool signature: async Task (MCP SDK requirement) [McpServerTool] -public static async Task ExcelPowerQuery(string action, ...) +public static async Task PptSlide(string action, ...) { // Action methods: synchronous (no await!) return action.ToLowerInvariant() switch @@ -166,17 +166,17 @@ public static async Task ExcelPowerQuery(string action, ...) }; } -// Action methods forward to in-process ExcelMcpService: +// Action methods forward to in-process PptMcpService: private static string ForwardList(string sessionId) { - return ExcelToolsBase.ForwardToService("powerquery.list", sessionId); + return PptToolsBase.ForwardToService("slide.list", sessionId); } -private static string ForwardView(string sessionId, string queryName) +private static string ForwardView(string sessionId, string slideIndex) { - if (string.IsNullOrEmpty(queryName)) - ExcelToolsBase.ThrowMissingParameter("queryName", "view"); - return ExcelToolsBase.ForwardToService("powerquery.view", sessionId, new { queryName }); + if (string.IsNullOrEmpty(slideIndex)) + PptToolsBase.ThrowMissingParameter("slideIndex", "view"); + return PptToolsBase.ForwardToService("slide.view", sessionId, new { slideIndex }); } ``` @@ -191,7 +191,7 @@ return JsonSerializer.Serialize(result, JsonOptions); ## JSON Deserialization & COM Marshalling -**⚠️ CRITICAL:** MCP deserializes JSON arrays to `JsonElement`, NOT primitives. Excel COM requires proper types. +**⚠️ CRITICAL:** MCP deserializes JSON arrays to `JsonElement`, NOT primitives. PowerPoint COM requires proper types. **Problem:** `values: [["text", 123, true]]` → `List>` where each object is `JsonElement`. @@ -234,13 +234,13 @@ private static object ConvertToCellValue(object? value) **❌ WRONG: Verbose guidance (LLM doesn't need step-by-step instructions)** ```csharp -errorMessage = "Operation failed. This usually means: (1) Sheet doesn't exist, (2) Range invalid, or (3) Session closed. " + - "Use worksheet(action: 'list') to verify sheet exists, then file(action: 'list') to check sessions."; +errorMessage = "Operation failed. This usually means: (1) Slide doesn't exist, (2) Shape not found, or (3) Session closed. " + + "Use slide(action: 'list') to verify slide exists, then file(action: 'list') to check sessions."; ``` **✅ CORRECT: State facts (LLM determines next action)** ```csharp -errorMessage = $"Cannot read range '{range}' on sheet '{sheet}': {ex.Message}"; +errorMessage = $"Cannot read shape '{shape}' on slide '{slide}': {ex.Message}"; ``` **Why:** LLMs are intelligent agents that determine workflow. Error messages should report what failed and why, not prescribe solutions. @@ -295,7 +295,7 @@ Before committing MCP tool changes: - [ ] Build passes with 0 warnings - [ ] No `if (!result.Success) throw McpException` blocks (violates MCP spec) - [ ] **Tool XML documentation (`/// `) documents server-specific behavior** -- [ ] **Non-enum parameter values explained (loadDestination, formatCode, etc.)** +- [ ] **Non-enum parameter values explained (layoutType, shapeType, etc.)** - [ ] **Performance guidance (batch mode) is accurate** - [ ] **Related tools referenced correctly** @@ -327,7 +327,7 @@ Before committing MCP tool changes: 2. ✅ Server-specific behavior is documented (defaults, quirks, important notes) 3. ✅ Performance guidance (batch mode) is accurate 4. ✅ Related tools referenced correctly -5. ✅ Non-enum parameter guidance is complete (loadDestination options, format codes, etc.) +5. ✅ Non-enum parameter guidance is complete (layoutType options, shape types, etc.) **What NOT to include in descriptions:** - ❌ **Enum action lists** - MCP SDK auto-generates enum values in schema (LLMs see them automatically) @@ -337,17 +337,18 @@ Before committing MCP tool changes: **Example - Good tool description:** ```csharp /// -/// Manage Power Query M code and data loading. +/// Manage slides in a PowerPoint presentation. /// -/// LOAD DESTINATIONS (non-enum parameter): -/// - 'worksheet': Load to worksheet as table (DEFAULT - users can see/validate data) -/// - 'data-model': Load to Power Pivot Data Model (ready for DAX measures/relationships) -/// - 'both': Load to BOTH worksheet AND Data Model -/// - 'connection-only': Don't load data (M code imported but not executed) +/// LAYOUT OPTIONS (non-enum parameter): +/// - 'blank': Empty slide with no placeholders (DEFAULT) +/// - 'title': Title slide layout +/// - 'title-content': Title and content layout +/// - 'section-header': Section header layout +/// - 'two-content': Two content areas side by side /// -/// TIMEOUT: Long-running refresh/load operations auto-timeout after 5 minutes. +/// TIMEOUT: Long-running operations auto-timeout after 5 minutes. /// -/// Use datamodel tool for DAX measures after loading to Data Model. +/// Use shape tool for adding content to slides. /// ``` ✅ Describes purpose and use cases diff --git a/.github/instructions/meta.instructions.md b/.github/instructions/meta.instructions.md index 6cd4d41c..5ff4f032 100644 --- a/.github/instructions/meta.instructions.md +++ b/.github/instructions/meta.instructions.md @@ -10,7 +10,7 @@ applyTo: ".github/**/*.md,.github/instructions/**" ### File Naming - ✅ MUST end with `.instructions.md` -- ✅ Use descriptive names: `excel-com-interop.instructions.md` +- ✅ Use descriptive names: `ppt-com-interop.instructions.md` - ❌ DON'T use generic names: `notes.instructions.md` ### Frontmatter Required diff --git a/.github/instructions/ppt-com-interop.instructions.md b/.github/instructions/ppt-com-interop.instructions.md new file mode 100644 index 00000000..b63456d6 --- /dev/null +++ b/.github/instructions/ppt-com-interop.instructions.md @@ -0,0 +1,297 @@ +--- +applyTo: "src/PptMcp.Core/**/*.cs" +--- + +# PowerPoint COM Interop Patterns + +> **Essential patterns for PowerPoint COM automation** + +## Core Principles + +1. **Use Late Binding** - `dynamic` types with `Type.GetTypeFromProgID()` +2. **1-Based Indexing** - PowerPoint collections start at 1, not 0 +3. **Exception Propagation** - Never wrap in try-catch, let batch.Execute() handle exceptions (see Exception Propagation section) + +## Reference Resources + +**NetOffice Library** - THE BEST source for ALL PowerPoint COM Interop patterns: +- GitHub: https://github.com/NetOfficeFw/NetOffice +- **Use for ALL COM Interop work** - slides, shapes, presentations, charts, tables, VBA, everything +- NetOffice wraps Office COM APIs in strongly-typed C# - study their patterns for dynamic interop conversion +- Search NetOffice repository BEFORE implementing any PowerPoint COM automation +- Particularly valuable for: Shapes, Slide layouts, Masters, Animations, complex COM scenarios + +## Exception Propagation Pattern (CRITICAL) + +**Core Commands: NEVER wrap operations in try-catch blocks that return error results. Let exceptions propagate naturally.** + +```csharp +// ❌ WRONG: Catching and wrapping exceptions +public async Task CreateAsync(IPptBatch batch, string name) +{ + try + { + return await batch.Execute((ctx, ct) => { + var item = ctx.Create(name); + return ValueTask.FromResult(new OperationResult { Success = true }); + }); + } + catch (Exception ex) + { + // ❌ WRONG: Double-wrapping suppresses the exception + return new OperationResult { Success = false, ErrorMessage = ex.Message }; + } +} + +// ✅ CORRECT: Let batch.Execute() handle exceptions via TaskCompletionSource +public async Task CreateAsync(IPptBatch batch, string name) +{ + return await batch.Execute((ctx, ct) => { + var item = ctx.Create(name); + return ValueTask.FromResult(new OperationResult { Success = true }); + }); + // Exception flows to batch.Execute() → caught via TaskCompletionSource + // → Returns OperationResult { Success = false, ErrorMessage } +} + +// ✅ CORRECT: Finally blocks are the right place for COM resource cleanup +public async Task ComplexAsync(IPptBatch batch, string name) +{ + dynamic? temp = null; + try + { + return await batch.Execute((ctx, ct) => { + temp = ctx.CreateTemp(name); + // ... operation ... + return ValueTask.FromResult(new OperationResult { Success = true }); + }); + } + finally + { + // ✅ Finally for resource cleanup, NOT catch for error handling + if (temp != null) + { + ComUtilities.Release(ref temp!); + } + } +} +``` + +**Why This Pattern:** +- `batch.Execute()` ALREADY captures exceptions via `TaskCompletionSource` +- Inner try-catch suppresses exceptions, causing double-wrapping and lost stack context +- Finally blocks work perfectly for COM resource cleanup (which must happen regardless of exception) +- Exception occurs at correct layer (batch), not suppressed at method level + +**Safe Exception Handling (Keep these):** +- ✅ Loop continuations: `catch { continue; }` (safe, recovers loop) +- ✅ Optional property access: `catch { value = null; }` (safe, uses fallback) +- ✅ Specific error routing: `catch (COMException ex) when (ex.HResult == code) { ... }` (specific, not general) +- ✅ Finally blocks: Resource cleanup for COM objects (always needed) + +**Pattern to Remove:** +- ❌ `catch (Exception ex) { return new Result { Success = false, ErrorMessage = ex.Message }; }` + +**Architecture:** +``` +Core Command (NO try-catch wrapping) + └─> await batch.Execute() + └─> TaskCompletionSource captures exception + └─> Returns OperationResult { Success = false, ErrorMessage } +``` + +--- + +## Resource Management + +### ✅ Unified Shutdown Pattern (Current Standard) + +**All presentation close and PowerPoint quit operations use `PptShutdownService` with resilient retry:** + +```csharp +// In PptBatch, PptSession, FileCommands: +PptShutdownService.CloseAndQuit(presentation, powerpoint, save: false, filePath, logger); +``` + +**Shutdown Order:** +1. **Optional Save** - If `save=true`, calls `presentation.Save()` explicitly before close +2. **Close Presentation** - Calls `presentation.Close()` (save param controls PowerPoint's prompt behavior) +3. **Release Presentation** - Releases COM reference via `ComUtilities.Release()` +4. **Quit PowerPoint** - Calls `powerpoint.Quit()` with exponential backoff retry (6 attempts, 200ms base delay) +5. **Release PowerPoint** - Releases COM reference via `ComUtilities.Release()` +6. **Automatic GC** - RCW finalizers handle final cleanup automatically (no forced GC needed per Microsoft guidance) + +**Resilience Features:** +- Uses `Microsoft.Extensions.Resilience` retry pipeline +- **Outer timeout (30s)**: Overall cancellation for PowerPoint.Quit() - catches hung PowerPoint (modal dialogs, deadlocks) +- **Inner retry**: Exponential backoff (200ms base, 2x factor, 6 attempts) for transient COM busy errors +- Retries on: `RPC_E_SERVERCALL_RETRYLATER` (-2147417851), `RPC_E_CALL_REJECTED` (-2147418111) +- Structured logging for diagnostics (attempt number, HResult, elapsed time) +- Continues with COM cleanup even if Quit fails/times out +- **STA thread join (45s)**: Must be >= PowerPointQuitTimeout + margin (currently 30s + 15s) to ensure Dispose() waits for full cleanup + +**Save Semantics:** +```csharp +// Discard changes (default for disposal paths) +PptShutdownService.CloseAndQuit(presentation, powerpoint, save: false, filePath, logger); + +// Save before close (for explicit save operations) +PptShutdownService.CloseAndQuit(presentation, powerpoint, save: true, filePath, logger); +``` + +**Why Unified Service:** +- Eliminates duplicated try/catch blocks across `PptBatch`, `PptSession`, `FileCommands` +- Consistent retry behavior for all PowerPoint quit operations +- Centralized logging and diagnostics +- Handles edge cases: disconnected COM proxies, hung PowerPoint, modal dialogs + +**Timeout Architecture (Proper Layering):** +``` +Overall Quit Timeout: 30 seconds (outer) + └─> Resilient Retry: 6 attempts with exponential backoff (inner, ~6s max) + └─> Individual Quit() calls + └─> STA Thread Join: 45 seconds (PowerPointQuitTimeout + 15s margin) +``` +- **30s quit timeout**: Catches truly hung PowerPoint (modal dialogs, deadlocks) via CancellationToken +- **6-attempt retry**: Handles transient COM busy states within the 30s window +- **45s thread join**: Must be >= PowerPointQuitTimeout + margin to ensure Dispose() waits for full cleanup + +## COM Object Cleanup Pattern (CRITICAL) + +**ALWAYS use try-finally for COM object cleanup. NEVER use catch blocks to swallow exceptions.** + +### ❌ WRONG Patterns + +```csharp +// WRONG #1: COM cleanup in try block (won't execute if exception occurs) +try +{ + dynamic pivotLayout = chart.PivotLayout; + dynamic pivotTable = pivotLayout.PivotTable; + name = pivotTable.Name?.ToString() ?? string.Empty; + ComUtilities.Release(ref pivotTable!); // ❌ Won't execute if exception above! + ComUtilities.Release(ref pivotLayout!); +} +catch +{ + name = "(unknown)"; // ❌ Swallows exception, causes COM leak +} + +// WRONG #2: Empty catch block (swallows exceptions silently) +try +{ + dynamic item = GetItem(); + // ... operations ... + ComUtilities.Release(ref item!); +} +catch +{ + // ❌ Empty catch - swallows exception, no cleanup +} +``` + +### ✅ CORRECT Pattern + +```csharp +// CORRECT: Finally block ensures cleanup regardless of exceptions +dynamic? pivotLayout = null; +dynamic? pivotTable = null; +try +{ + pivotLayout = chart.PivotLayout; + pivotTable = pivotLayout.PivotTable; + name = pivotTable.Name?.ToString() ?? string.Empty; +} +finally +{ + // ✅ ALWAYS executes - exception or no exception + if (pivotTable != null) ComUtilities.Release(ref pivotTable!); + if (pivotLayout != null) ComUtilities.Release(ref pivotLayout!); +} +// ✅ Exception propagates naturally to batch.Execute() +``` + +**Pattern Requirements:** +1. **Declare COM objects as `dynamic?` nullable** before try block +2. **Initialize to `null`** +3. **Acquire COM objects in try block** +4. **Release in finally block** with null checks +5. **NO catch blocks** unless specific exception handling required +6. **NEVER catch to set fallback values** - let exceptions propagate + +**Why This Matters:** +- Finally blocks execute **regardless** of exceptions (try succeeds or fails) +- COM objects leak if Release() not reached before exception +- Swallowing exceptions with catch blocks hides real problems from batch.Execute() +- Empty catch blocks are code smell - remove them entirely +- Let exceptions propagate naturally to batch.Execute() for proper error handling + +**See Also:** +- CRITICAL-RULES.md Rule 22 for complete requirements +- CRITICAL-RULES.md Rule 1b for exception propagation pattern + +## Critical COM Issues + +### 1. PowerPoint Collections Are 1-Based +```csharp +// ❌ WRONG: collection.Item(0) +// ✅ CORRECT: collection.Item(1) +for (int i = 1; i <= collection.Count; i++) { var item = collection.Item(i); } +``` + +### 2. Numeric Property Type Conversions + +**⚠️ ALL PowerPoint COM numeric properties return `double`, NOT `int`!** + +```csharp +// ❌ WRONG: Implicit conversion fails at runtime +int slideIndex = slide.SlideIndex; // Runtime error: Cannot convert double to int +int shapeCount = slide.Shapes.Count; // Runtime error: Cannot convert double to int + +// ✅ CORRECT: Explicit conversion required +int slideIndex = Convert.ToInt32(slide.SlideIndex); +int shapeCount = Convert.ToInt32(slide.Shapes.Count); +``` + +**Common Properties Affected:** +- `Slide.SlideIndex` → `double` (not `int`) +- `Shape.Left`, `Shape.Top`, `Shape.Width`, `Shape.Height` → `double` (not `float`) +- Any numeric property from PowerPoint COM → assume `double` + +**Why:** PowerPoint COM uses `VARIANT` types internally, which represent numbers as `double`. C# `dynamic` binding preserves this type. + +### 3. PowerPoint Busy Handling +```csharp +catch (COMException ex) when (ex.HResult == -2147417851) +{ + // RPC_E_SERVERCALL_RETRYLATER - PowerPoint is busy +} +``` + +## Common Patterns + +### Read Slide Content +```csharp +dynamic slide = presentation.Slides[1]; +dynamic shapes = slide.Shapes; +for (int i = 1; i <= shapes.Count; i++) { var shape = shapes.Item(i); } +``` + +### Add Shape +```csharp +dynamic shape = slide.Shapes.AddShape(msoShapeType, left, top, width, height); +shape.TextFrame.TextRange.Text = "Hello"; +``` + +--- + +## Common Mistakes + +| Mistake | Fix | +|---------|-----| +| 0-based indexing | PowerPoint is 1-based | +| Not releasing objects | `try/finally` + `ReleaseComObject()` | +| `int x = shape.Property` | Use `Convert.ToInt32()` for ALL numeric properties | +| Assuming enum types | Numeric properties return `double`, convert to enum | + +**📚 Reference:** [PowerPoint Object Model](https://docs.microsoft.com/en-us/office/vba/api/overview/powerpoint) diff --git a/.github/instructions/ppt-com-patterns-guide.instructions.md b/.github/instructions/ppt-com-patterns-guide.instructions.md new file mode 100644 index 00000000..367ef517 --- /dev/null +++ b/.github/instructions/ppt-com-patterns-guide.instructions.md @@ -0,0 +1,349 @@ +--- +applyTo: "src/PptMcp.Core/Commands/**/*.cs,tests/**/*.cs" +--- + +# PowerPoint COM Patterns - Quick Reference + +> **Essential patterns for PowerPoint COM automation via late binding** + +## Core Principles + +1. **Use Late Binding** - `dynamic` types with `Type.GetTypeFromProgID("PowerPoint.Application")` +2. **1-Based Indexing** - All PowerPoint collections (Slides, Shapes, Paragraphs) start at 1 +3. **Exception Propagation** - Never wrap in try-catch; let `batch.Execute()` handle exceptions +4. **msoTrue / msoFalse** - PowerPoint uses `MsoTriState`: `msoTrue = -1`, `msoFalse = 0`, `msoCTrue = 1` +5. **Points as Units** - Positions and sizes are in points (1 inch = 72 points) + +--- + +## Decision Tree: Which Tool to Use + +``` +Working with presentations? +├─ Slide lifecycle (add, delete, duplicate, move, reorder)? +│ └─ Use slide tool +│ +├─ Shapes on a slide (create, modify, delete, position)? +│ └─ Use shape tool +│ +├─ Text inside a shape (read, write, format)? +│ └─ Use text / shape tool (TextFrame access) +│ +├─ Charts embedded in a slide? +│ └─ Use chart tool +│ +├─ Placeholders (title, body, footer)? +│ └─ Use placeholder tool +│ +└─ Presentation-level properties (slide size, metadata)? + └─ Use file / presentation tool +``` + +--- + +## Slide Operations + +### Adding Slides + +```csharp +// Get a slide layout from the first slide master +dynamic slideMaster = presentation.SlideMasters.Item(1); +dynamic customLayout = slideMaster.CustomLayouts.Item(layoutIndex); // 1-based + +// Add slide at a specific position +dynamic newSlide = presentation.Slides.AddSlide(position, customLayout); +``` + +### Navigating Slides + +```csharp +// By index (1-based) +dynamic slide = presentation.Slides.Item(slideIndex); + +// By SlideID (stable across reorders) +dynamic slide = presentation.Slides.FindBySlideID(slideId); + +// Iterate all slides +for (int i = 1; i <= presentation.Slides.Count; i++) +{ + dynamic slide = presentation.Slides.Item(i); + // ... process slide ... + ComUtilities.Release(ref slide!); +} +``` + +### Deleting and Reordering + +```csharp +// Delete +slide.Delete(); + +// Move to new position +slide.MoveTo(newPosition); // 1-based target index + +// Duplicate (returns SlideRange, not single slide) +dynamic slideRange = slide.Duplicate(); +dynamic duplicated = slideRange.Item(1); +``` + +--- + +## Shape Lifecycle + +### Creating Shapes + +```csharp +// Basic shape (left, top, width, height in points) +dynamic shape = slide.Shapes.AddShape( + 1, // msoShapeRectangle + 100f, // left + 100f, // top + 200f, // width + 150f // height +); + +// Text box +dynamic textBox = slide.Shapes.AddTextbox( + 1, // msoTextOrientationHorizontal + 50f, 50f, 300f, 100f +); + +// Picture +dynamic picture = slide.Shapes.AddPicture( + filePath, + 0, // msoFalse = don't link + -1, // msoTrue = save with document + left, top, width, height +); + +// Table +dynamic table = slide.Shapes.AddTable( + numRows, numColumns, + left, top, width, height +); +``` + +### Modifying Shapes + +```csharp +// Position and size +shape.Left = 100f; +shape.Top = 50f; +shape.Width = 300f; +shape.Height = 200f; + +// Rotation (degrees) +shape.Rotation = 45f; + +// Name (for identification) +shape.Name = "MyShape"; + +// Visibility +shape.Visible = -1; // msoTrue +``` + +### Deleting Shapes + +```csharp +shape.Delete(); +// Release COM reference immediately after delete +ComUtilities.Release(ref shape!); +``` + +### Shape Type Detection + +Use `shape.Type` (`MsoShapeType` enum values): + +| Value | Constant | Description | +|-------|----------|-------------| +| 1 | msoAutoShape | Basic shapes | +| 6 | msoGroup | Grouped shapes | +| 13 | msoPicture | Images | +| 14 | msoPlaceholder | Placeholder shapes | +| 17 | msoTextBox | Text boxes | +| 19 | msoTable | Tables | +| 3 | msoChart | Charts | + +```csharp +int shapeType = Convert.ToInt32(shape.Type); +if (shapeType == 14) // msoPlaceholder +{ + int phType = Convert.ToInt32(shape.PlaceholderFormat.Type); +} +``` + +--- + +## Text Manipulation + +### TextFrame → TextRange → Font + +```csharp +dynamic? textFrame = null; +dynamic? textRange = null; +dynamic? font = null; +try +{ + // Access text content + textFrame = shape.TextFrame; + textRange = textFrame.TextRange; + + // Read text + string text = textRange.Text; + + // Write text + textRange.Text = "New content"; + + // Format text + font = textRange.Font; + font.Size = 24; + font.Bold = -1; // msoTrue + font.Italic = 0; // msoFalse + font.Color.RGB = 0xFF0000; // Red (BGR format in COM) + font.Name = "Calibri"; +} +finally +{ + if (font != null) ComUtilities.Release(ref font!); + if (textRange != null) ComUtilities.Release(ref textRange!); + if (textFrame != null) ComUtilities.Release(ref textFrame!); +} +``` + +### Paragraph-Level Formatting + +```csharp +dynamic? paragraphs = null; +dynamic? paragraph = null; +dynamic? paraFont = null; +try +{ + paragraphs = textFrame.TextRange.Paragraphs(); + for (int i = 1; i <= paragraphs.Count; i++) + { + paragraph = paragraphs.Item(i); + paraFont = paragraph.Font; + paraFont.Size = 18; + ComUtilities.Release(ref paraFont!); + ComUtilities.Release(ref paragraph!); + } +} +finally +{ + if (paraFont != null) ComUtilities.Release(ref paraFont!); + if (paragraph != null) ComUtilities.Release(ref paragraph!); + if (paragraphs != null) ComUtilities.Release(ref paragraphs!); +} +``` + +### HasTextFrame Check + +```csharp +// Not all shapes have text frames - check first +int hasText = Convert.ToInt32(shape.HasTextFrame); +if (hasText == -1) // msoTrue +{ + dynamic textFrame = shape.TextFrame; + // ... use textFrame ... + ComUtilities.Release(ref textFrame!); +} +``` + +--- + +## COM Object Cleanup (CRITICAL) + +### Standard Pattern + +```csharp +dynamic? shape = null; +dynamic? textFrame = null; +dynamic? textRange = null; +try +{ + shape = slide.Shapes.Item(1); + textFrame = shape.TextFrame; + textRange = textFrame.TextRange; + // ... operations ... +} +finally +{ + // Release in reverse acquisition order + if (textRange != null) ComUtilities.Release(ref textRange!); + if (textFrame != null) ComUtilities.Release(ref textFrame!); + if (shape != null) ComUtilities.Release(ref shape!); +} +``` + +### Loop Cleanup + +```csharp +for (int i = 1; i <= slide.Shapes.Count; i++) +{ + dynamic? shape = null; + try + { + shape = slide.Shapes.Item(i); + // ... process shape ... + } + finally + { + if (shape != null) ComUtilities.Release(ref shape!); + } +} +``` + +### Reverse-Order Deletion in Loops + +```csharp +// When deleting shapes, iterate in reverse to avoid index shifting +for (int i = slide.Shapes.Count; i >= 1; i--) +{ + dynamic? shape = null; + try + { + shape = slide.Shapes.Item(i); + if (ShouldDelete(shape)) + { + shape.Delete(); + } + } + finally + { + if (shape != null) ComUtilities.Release(ref shape!); + } +} +``` + +--- + +## Common PowerPoint COM Quirks + +| Quirk | Detail | +|-------|--------| +| **1-based indexing** | All collections: `Slides.Item(1)`, `Shapes.Item(1)`, `Paragraphs(1)` | +| **msoTrue = -1** | Boolean tri-state: `msoTrue = -1`, `msoFalse = 0`, `msoCTrue = 1` | +| **Points, not pixels** | Positions/sizes in points (72 points = 1 inch) | +| **BGR color order** | `Color.RGB` uses BGR: red = `0x0000FF`, blue = `0xFF0000` | +| **double returns** | Numeric properties return `double`; use `Convert.ToInt32()` | +| **HasTextFrame** | Must check before accessing `TextFrame`; not all shapes support it | +| **SlideID vs Index** | `SlideIndex` changes on reorder; `SlideID` is stable | +| **Placeholder access** | Use `shape.PlaceholderFormat.Type` only when `shape.Type == 14` | +| **Delete reindexes** | Deleting shape/slide shifts subsequent indices; iterate in reverse | +| **GroupItems** | Grouped shapes: access children via `shape.GroupItems.Item(i)` | + +--- + +## Common Mistakes + +| Mistake | Fix | +|---------|-----| +| 0-based indexing | PowerPoint is 1-based | +| `bool` for tri-state | Use `int`: -1 (true), 0 (false) | +| Pixel measurements | Use points (72pt = 1 inch) | +| RGB color order | PowerPoint COM uses BGR | +| `int x = shape.Left` | Use `Convert.ToSingle()` or `float` for position properties | +| Missing HasTextFrame check | Always check before accessing TextFrame | +| Forward-loop deletion | Iterate in reverse when deleting | +| Not releasing COM objects | `try-finally` + `ComUtilities.Release()` | +| Catching exceptions in Core | Let `batch.Execute()` handle via TaskCompletionSource | diff --git a/.github/instructions/readme-management.instructions.md b/.github/instructions/readme-management.instructions.md index 7f3befdb..c4421db7 100644 --- a/.github/instructions/readme-management.instructions.md +++ b/.github/instructions/readme-management.instructions.md @@ -9,7 +9,7 @@ applyTo: "**/*.md,README.md,**/README.md,**/index.md" | File | Lines | Audience | Purpose | |------|-------|----------|---------| | `/README.md` | 250-300 | All users | Comprehensive reference | -| `/src/ExcelMcp.McpServer/README.md` | 80-100 | .NET devs | Concise NuGet gateway | +| `/src/PptMcp.McpServer/README.md` | 80-100 | .NET devs | Concise NuGet gateway | | `/vscode-extension/README.md` | 100-120 | VS Code users | User benefits focus | | `/gh-pages/index.md` | 450-5000 | All users | Comprehensive reference | @@ -27,15 +27,15 @@ You need to make sure that the `Features.md` file is up-to-date with the latest Before updating counts, verify by counting: -- **MCP Server**: Count tool files (excel_batch handled via ExcelTools.cs, not separate tool file) +- **MCP Server**: Count tool files (ppt_batch handled via PptTools.cs, not separate tool file) - **CLI**: Count command group folders (includes Session commands) - **Operations**: Count separately for each - they differ! Sync counts across: - - GitHub Project About: https://github.com/sbroenne/mcp-server-excel (use the GitHub CLI to update) + - GitHub Project About: https://github.com/trsdn/mcp-server-ppt (use the GitHub CLI to update) - `/README.md` - - `/src/ExcelMcp.McpServer/README.md` - - `/src/ExcelMcp.CLI/README.md` + - `/src/PptMcp.McpServer/README.md` + - `/src/PptMcp.CLI/README.md` - `/vscode-extension/README.md` - `/gh-pages/index.md` - `/FEATURES.md` @@ -102,7 +102,7 @@ The project uses a **centralized changelog** at `/CHANGELOG.md` covering all com gh-pages uses Jekyll includes to pull content from source READMEs: -1. **Source file** (e.g., `src/ExcelMcp.McpServer/README.md`) +1. **Source file** (e.g., `src/PptMcp.McpServer/README.md`) 2. **build.sh copies** to `_includes/mcp-server.md` (stripping H1, badges) 3. **Page file** (e.g., `gh-pages/mcp-server.md`) uses Jekyll include 4. **Result**: Local URL `/mcp-server/` instead of GitHub link @@ -114,8 +114,8 @@ gh-pages uses Jekyll includes to pull content from source READMEs: | `/features/` | `/FEATURES.md` | `gh-pages/features.md` | | `/installation/` | `/docs/INSTALLATION.md` | `gh-pages/installation.md` | | `/changelog/` | `/CHANGELOG.md` | `gh-pages/changelog.md` | -| `/mcp-server/` | `/src/ExcelMcp.McpServer/README.md` | `gh-pages/mcp-server.md` | -| `/cli/` | `/src/ExcelMcp.CLI/README.md` | `gh-pages/cli.md` | +| `/mcp-server/` | `/src/PptMcp.McpServer/README.md` | `gh-pages/mcp-server.md` | +| `/cli/` | `/src/PptMcp.CLI/README.md` | `gh-pages/cli.md` | | `/skills/` | `/skills/README.md` | `gh-pages/skills.md` | | `/contributing/` | `/docs/CONTRIBUTING.md` | `gh-pages/contributing.md` | diff --git a/.github/instructions/testing-strategy.instructions.md b/.github/instructions/testing-strategy.instructions.md index 4dc4997d..1f43b94d 100644 --- a/.github/instructions/testing-strategy.instructions.md +++ b/.github/instructions/testing-strategy.instructions.md @@ -11,54 +11,54 @@ applyTo: "tests/**/*.cs" ### Core.Tests (Business Logic) ```bash # Development (fast - excludes VBA and Screenshot) -dotnet test tests/ExcelMcp.Core.Tests/ExcelMcp.Core.Tests.csproj --filter "Category=Integration&RunType!=OnDemand&Feature!=VBA&Feature!=VBATrust&Feature!=Screenshot" +dotnet test tests/PptMcp.Core.Tests/PptMcp.Core.Tests.csproj --filter "Category=Integration&RunType!=OnDemand&Feature!=VBA&Feature!=VBATrust&Feature!=Screenshot" # Diagnostic tests (validate patterns, slow ~20s each) -dotnet test tests/ExcelMcp.Diagnostics.Tests/ExcelMcp.Diagnostics.Tests.csproj --filter "RunType=OnDemand&Layer=Diagnostics" +dotnet test tests/PptMcp.Diagnostics.Tests/PptMcp.Diagnostics.Tests.csproj --filter "RunType=OnDemand&Layer=Diagnostics" # VBA tests (manual only - requires VBA trust) -dotnet test tests/ExcelMcp.Core.Tests/ExcelMcp.Core.Tests.csproj --filter "(Feature=VBA|Feature=VBATrust)&RunType!=OnDemand" +dotnet test tests/PptMcp.Core.Tests/PptMcp.Core.Tests.csproj --filter "(Feature=VBA|Feature=VBATrust)&RunType!=OnDemand" # Screenshot tests (isolated run only - clipboard contention when parallel) -dotnet test tests/ExcelMcp.Core.Tests/ExcelMcp.Core.Tests.csproj --filter "Feature=Screenshot" +dotnet test tests/PptMcp.Core.Tests/PptMcp.Core.Tests.csproj --filter "Feature=Screenshot" # Specific feature -dotnet test tests/ExcelMcp.Core.Tests/ExcelMcp.Core.Tests.csproj --filter "Feature=PowerQuery" +dotnet test tests/PptMcp.Core.Tests/PptMcp.Core.Tests.csproj --filter "Feature=Slide" ``` ### ComInterop.Tests (Session/Batch Infrastructure) ```bash # Session/batch changes (MANDATORY - see CRITICAL-RULES.md Rule 3) -dotnet test tests/ExcelMcp.ComInterop.Tests/ExcelMcp.ComInterop.Tests.csproj --filter "RunType=OnDemand" +dotnet test tests/PptMcp.ComInterop.Tests/PptMcp.ComInterop.Tests.csproj --filter "RunType=OnDemand" ``` ### McpServer.Tests (End-to-End Tool Tests) ```bash # All MCP tool tests -dotnet test tests/ExcelMcp.McpServer.Tests/ExcelMcp.McpServer.Tests.csproj +dotnet test tests/PptMcp.McpServer.Tests/PptMcp.McpServer.Tests.csproj # Specific tool -dotnet test tests/ExcelMcp.McpServer.Tests/ExcelMcp.McpServer.Tests.csproj --filter "FullyQualifiedName~PowerQueryTool" +dotnet test tests/PptMcp.McpServer.Tests/PptMcp.McpServer.Tests.csproj --filter "FullyQualifiedName~SlideTool" ``` ### CLI.Tests (Command-Line Interface) ```bash # All CLI tests -dotnet test tests/ExcelMcp.CLI.Tests/ExcelMcp.CLI.Tests.csproj +dotnet test tests/PptMcp.CLI.Tests/PptMcp.CLI.Tests.csproj # Specific command -dotnet test tests/ExcelMcp.CLI.Tests/ExcelMcp.CLI.Tests.csproj --filter "FullyQualifiedName~PowerQuery" +dotnet test tests/PptMcp.CLI.Tests/PptMcp.CLI.Tests.csproj --filter "FullyQualifiedName~Slide" ``` ### Run Specific Test by Name ```bash # Use full project path + filter -dotnet test tests/ExcelMcp.Core.Tests/ExcelMcp.Core.Tests.csproj --filter "FullyQualifiedName~TestMethodName" +dotnet test tests/PptMcp.Core.Tests/PptMcp.Core.Tests.csproj --filter "FullyQualifiedName~TestMethodName" ``` ## Round-Trip Validation Pattern -**Always verify actual Excel state after operations:** +**Always verify actual PowerPoint state after operations:** ```csharp // ✅ CREATE → Verify exists @@ -117,11 +117,11 @@ Assert.DoesNotContain(file1Content, viewResult.Content); // ✅ file1 content g | Mistake | Fix | |---------|-----| | Shared test file | Each test creates unique file | -| Only test success flag | Verify actual Excel state | +| Only test success flag | Verify actual PowerPoint state | | Save before assertions | Remove Save entirely | | Save in middle of test | Only at end or in persistence test | | Manual IDisposable | Use `IClassFixture` | -| .xlsx for VBA tests | Use `.xlsm` | +| .pptx for VBA tests | Use `.pptm` | | "Accept both" assertions | Binary assertions only | | Missing Feature trait | Add from valid feature list above | @@ -131,7 +131,7 @@ Assert.DoesNotContain(file1Content, viewResult.Content); // ✅ file1 content g 2. Check file isolation (unique files?) 3. Check assertions (binary, not conditional?) 4. Check Save (removed unless persistence test?) -5. Verify Excel state (not just success flag?) +5. Verify PowerPoint state (not just success flag?) **Full checklist**: See CRITICAL-RULES.md Rule 12 @@ -139,9 +139,9 @@ Assert.DoesNotContain(file1Content, viewResult.Content); // ✅ file1 content g ## LLM Integration Tests -**Location**: `tests/ExcelMcp.LLM.Tests/` +**Location**: `tests/PptMcp.LLM.Tests/` -**Purpose**: Validate that LLMs correctly use Excel MCP Server and CLI tools using [pytest-aitest](https://github.com/sbroenne/pytest-aitest). +**Purpose**: Validate that LLMs correctly use PowerPoint MCP Server and CLI tools using [pytest-aitest](https://github.com/sbroenne/pytest-aitest). ### When to Run @@ -153,7 +153,7 @@ Assert.DoesNotContain(file1Content, viewResult.Content); // ✅ file1 content g ```powershell # Navigate to the LLM tests directory first -cd d:\source\mcp-server-excel\tests\ExcelMcp.LLM.Tests +cd d:\source\mcp-server-ppt\tests\PptMcp.LLM.Tests # Install deps (local pytest-aitest path is configured via tool.uv.sources) uv sync @@ -171,18 +171,18 @@ uv run pytest -m aitest -v ### Prerequisites - `AZURE_OPENAI_ENDPOINT` environment variable -- Windows desktop with Excel installed +- Windows desktop with PowerPoint installed - MCP Server built (Release) and CLI available on PATH ### Configuration Overrides -- `EXCEL_MCP_SERVER_COMMAND` to override MCP server command -- `EXCEL_CLI_COMMAND` to override CLI command +- `ppt_mcp_SERVER_COMMAND` to override MCP server command +- `PPT_CLI_COMMAND` to override CLI command ### Test Results -Reports are generated in `tests/ExcelMcp.LLM.Tests/TestResults/`: +Reports are generated in `tests/PptMcp.LLM.Tests/TestResults/`: - `report.html` - Visual HTML report - `report.json` - Machine-readable JSON -See `tests/ExcelMcp.LLM.Tests/README.md` for complete documentation. +See `tests/PptMcp.LLM.Tests/README.md` for complete documentation. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 2bca5b14..2c28a498 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -18,20 +18,20 @@ Relates to #[issue number] - Change 3 ## Testing Performed -- [ ] Tested manually with various Excel files -- [ ] Verified Excel process cleanup (no excel.exe remains after 5 seconds) +- [ ] Tested manually with various PowerPoint files +- [ ] Verified PowerPoint process cleanup (no powerpnt.exe remains after 5 seconds) - [ ] Tested error conditions (missing files, invalid arguments, etc.) - [ ] All existing commands still work - [ ] VBA script execution tested (if applicable) -- [ ] XLSM file format validation tested (if applicable) +- [ ] PPTM file format validation tested (if applicable) - [ ] VBA trust setup tested (if applicable) - [ ] Build produces zero warnings ## Test Commands ```powershell # Commands used for testing -ExcelMcp command1 "test.xlsx" -ExcelMcp command2 "test.xlsx" "param" +PptMcp command1 "test.pptx" +PptMcp command2 "test.pptx" "param" ``` ## Screenshots (if applicable) @@ -47,7 +47,7 @@ If YES, verify all steps completed: - [ ] Implemented method in Core Commands class (e.g., `PowerQueryCommands.NewMethodAsync()`) - [ ] Added enum value to `ToolActions.cs` (e.g., `PowerQueryAction.NewMethod`) - [ ] Added `ToActionString` mapping to `ActionExtensions.cs` (e.g., `PowerQueryAction.NewMethod => "new-method"`) -- [ ] Added switch case to appropriate MCP Tool (e.g., `ExcelPowerQueryTool.cs`) +- [ ] Added switch case to appropriate MCP Tool (e.g., `PptPowerQueryTool.cs`) - [ ] Implemented MCP method that calls Core method - [ ] Build succeeds with 0 warnings (CS8524 compiler enforcement verified) - [ ] Updated `CORE-COMMANDS-AUDIT.md` (if significant addition) @@ -64,9 +64,9 @@ If YES, verify all steps completed: - [ ] Appropriate error handling added - [ ] Updated help text (if adding new commands) - [ ] Updated README.md (if needed) -- [ ] Follows Excel COM best practices from copilot-instructions.md +- [ ] Follows PowerPoint COM best practices from copilot-instructions.md - [ ] Uses batch API with proper disposal (`using var batch` or `await using var batch`) -- [ ] Properly handles 1-based Excel indexing +- [ ] Properly handles 1-based PowerPoint indexing - [ ] Escapes user input with `.EscapeMarkup()` - [ ] Returns consistent exit codes (0 = success, 1+ = error) diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index 050c3484..8c65d6d5 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -7,12 +7,12 @@ on: push: branches: [ main, develop ] paths: - - 'src/ExcelMcp.CLI/**' - - 'src/ExcelMcp.Core/**' - - 'src/ExcelMcp.ComInterop/**' - - 'tests/ExcelMcp.CLI.Tests/**' - - 'tests/ExcelMcp.Core.Tests/**' - - 'tests/ExcelMcp.ComInterop.Tests/**' + - 'src/PptMcp.CLI/**' + - 'src/PptMcp.Core/**' + - 'src/PptMcp.ComInterop/**' + - 'tests/PptMcp.CLI.Tests/**' + - 'tests/PptMcp.Core.Tests/**' + - 'tests/PptMcp.ComInterop.Tests/**' - 'global.json' - 'Directory.Build.props' - 'Directory.Packages.props' @@ -20,13 +20,13 @@ on: pull_request: branches: [ main ] paths: - - 'src/ExcelMcp.CLI/**' - - 'src/ExcelMcp.Core/**' - - 'src/ExcelMcp.ComInterop/**' - - 'tests/ExcelMcp.CLI.Tests/**' - - 'tests/ExcelMcp.Core.Tests/**' - - 'tests/ExcelMcp.Diagnostics.Tests/**' - - 'tests/ExcelMcp.ComInterop.Tests/**' + - 'src/PptMcp.CLI/**' + - 'src/PptMcp.Core/**' + - 'src/PptMcp.ComInterop/**' + - 'tests/PptMcp.CLI.Tests/**' + - 'tests/PptMcp.Core.Tests/**' + - 'tests/PptMcp.Diagnostics.Tests/**' + - 'tests/PptMcp.ComInterop.Tests/**' - 'global.json' - 'Directory.Build.props' - 'Directory.Packages.props' @@ -45,10 +45,10 @@ jobs: dotnet-version: 10.0.x - name: Restore dependencies - run: dotnet restore src/ExcelMcp.CLI/ExcelMcp.CLI.csproj + run: dotnet restore src/PptMcp.CLI/PptMcp.CLI.csproj - name: Build CLI - run: dotnet build src/ExcelMcp.CLI/ExcelMcp.CLI.csproj --no-restore --configuration Release + run: dotnet build src/PptMcp.CLI/PptMcp.CLI.csproj --no-restore --configuration Release - name: Verify Core Commands Coverage run: | @@ -63,14 +63,14 @@ jobs: - name: Verify CLI build run: | - # Check excelcli main executable - if (Test-Path "src/ExcelMcp.CLI/bin/Release/net10.0-windows/excelcli.exe") { - Write-Output "✅ excelcli.exe built successfully" - $version = (Get-Item "src/ExcelMcp.CLI/bin/Release/net10.0-windows/excelcli.exe").VersionInfo.FileVersion + # Check pptcli main executable + if (Test-Path "src/PptMcp.CLI/bin/Release/net10.0-windows/pptcli.exe") { + Write-Output "✅ pptcli.exe built successfully" + $version = (Get-Item "src/PptMcp.CLI/bin/Release/net10.0-windows/pptcli.exe").VersionInfo.FileVersion Write-Output "Version: $version" - # Test CLI help command (safe - no Excel COM required) - $helpOutput = & "src/ExcelMcp.CLI/bin/Release/net10.0-windows/excelcli.exe" --help 2>&1 + # Test CLI help command (safe - no PowerPoint COM required) + $helpOutput = & "src/PptMcp.CLI/bin/Release/net10.0-windows/pptcli.exe" --help 2>&1 $exitCode = $LASTEXITCODE if ($exitCode -eq 0) { @@ -84,21 +84,21 @@ jobs: exit 1 } } else { - Write-Error "❌ excelcli.exe not found" + Write-Error "❌ pptcli.exe not found" exit 1 } Write-Output "🔧 CLI tool ready for direct automation" shell: pwsh - - name: Test CLI (requires Excel) + - name: Test CLI (requires PowerPoint) run: | - Write-Output "ℹ️ Note: CLI tests skipped in CI - they require Microsoft Excel" - Write-Output " Run 'dotnet test tests/ExcelMcp.CLI.Tests/' locally with Excel installed" + Write-Output "ℹ️ Note: CLI tests skipped in CI - they require Microsoft PowerPoint" + Write-Output " Run 'dotnet test tests/PptMcp.CLI.Tests/' locally with PowerPoint installed" shell: pwsh - name: Upload CLI build artifacts uses: actions/upload-artifact@v4 with: - name: ExcelMcp-CLI-${{ github.sha }} - path: src/ExcelMcp.CLI/bin/Release/net10.0-windows/ + name: PptMcp-CLI-${{ github.sha }} + path: src/PptMcp.CLI/bin/Release/net10.0-windows/ diff --git a/.github/workflows/build-mcp-server.yml b/.github/workflows/build-mcp-server.yml index 8a4788af..7e290331 100644 --- a/.github/workflows/build-mcp-server.yml +++ b/.github/workflows/build-mcp-server.yml @@ -7,13 +7,13 @@ on: push: branches: [ main, develop ] paths: - - 'src/ExcelMcp.McpServer/**' - - 'src/ExcelMcp.Core/**' - - 'src/ExcelMcp.ComInterop/**' - - 'tests/ExcelMcp.McpServer.Tests/**' - - 'tests/ExcelMcp.Core.Tests/**' - - 'tests/ExcelMcp.Diagnostics.Tests/**' - - 'tests/ExcelMcp.ComInterop.Tests/**' + - 'src/PptMcp.McpServer/**' + - 'src/PptMcp.Core/**' + - 'src/PptMcp.ComInterop/**' + - 'tests/PptMcp.McpServer.Tests/**' + - 'tests/PptMcp.Core.Tests/**' + - 'tests/PptMcp.Diagnostics.Tests/**' + - 'tests/PptMcp.ComInterop.Tests/**' - 'global.json' - 'Directory.Build.props' - 'Directory.Packages.props' @@ -32,10 +32,10 @@ jobs: dotnet-version: 10.0.x - name: Restore dependencies - run: dotnet restore src/ExcelMcp.McpServer/ExcelMcp.McpServer.csproj + run: dotnet restore src/PptMcp.McpServer/PptMcp.McpServer.csproj - name: Build MCP Server - run: dotnet build src/ExcelMcp.McpServer/ExcelMcp.McpServer.csproj --no-restore --configuration Release + run: dotnet build src/PptMcp.McpServer/PptMcp.McpServer.csproj --no-restore --configuration Release - name: Verify Core Commands Coverage run: | @@ -51,19 +51,19 @@ jobs: - name: Verify MCP Server build run: | # Check MCP Server executable - if (Test-Path "src/ExcelMcp.McpServer/bin/Release/net10.0-windows/Sbroenne.ExcelMcp.McpServer.exe") { - Write-Output "✅ Sbroenne.ExcelMcp.McpServer.exe built successfully" - $mcpVersion = (Get-Item "src/ExcelMcp.McpServer/bin/Release/net10.0-windows/Sbroenne.ExcelMcp.McpServer.exe").VersionInfo.FileVersion + if (Test-Path "src/PptMcp.McpServer/bin/Release/net10.0-windows/PptMcp.McpServer.exe") { + Write-Output "✅ PptMcp.McpServer.exe built successfully" + $mcpVersion = (Get-Item "src/PptMcp.McpServer/bin/Release/net10.0-windows/PptMcp.McpServer.exe").VersionInfo.FileVersion Write-Output "📦 MCP Server Version: $mcpVersion" # Check for MCP server.json configuration - if (Test-Path "src/ExcelMcp.McpServer/bin/Release/net10.0-windows/.mcp/server.json") { + if (Test-Path "src/PptMcp.McpServer/bin/Release/net10.0-windows/.mcp/server.json") { Write-Output "✅ MCP server.json configuration found" } else { Write-Warning "⚠️ MCP server.json configuration not found" } } else { - Write-Error "❌ Sbroenne.ExcelMcp.McpServer.exe not found" + Write-Error "❌ PptMcp.McpServer.exe not found" exit 1 } @@ -73,5 +73,5 @@ jobs: - name: Upload MCP Server build artifacts uses: actions/upload-artifact@v4 with: - name: ExcelMcp-MCP-Server-${{ github.sha }} - path: src/ExcelMcp.McpServer/bin/Release/net10.0-windows/ + name: PptMcp-MCP-Server-${{ github.sha }} + path: src/PptMcp.McpServer/bin/Release/net10.0-windows/ diff --git a/.github/workflows/deploy-azure-runner.yml.disabled b/.github/workflows/deploy-azure-runner.yml.disabled deleted file mode 100644 index 4ee97dc4..00000000 --- a/.github/workflows/deploy-azure-runner.yml.disabled +++ /dev/null @@ -1,108 +0,0 @@ -name: Deploy Azure Self-Hosted Runner - -# This workflow deploys the Azure Windows VM with Bastion for Excel integration testing -# Uses OIDC (OpenID Connect) for secure Azure authentication -# Manual software installation required after deployment (Excel, .NET SDK, GitHub runner) -# Setup guide: infrastructure/azure/GITHUB_ACTIONS_DEPLOYMENT.md - -permissions: - contents: read - id-token: write # Required for OIDC authentication with Azure - -on: - workflow_dispatch: - inputs: - resource_group: - description: 'Azure Resource Group name' - required: true - default: 'rg-excel-runner' - admin_password: - description: 'VM Admin password (secure input)' - required: true - type: string - -jobs: - deploy-infrastructure: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Azure Login (OIDC) - uses: azure/login@v1 - with: - client-id: ${{ secrets.AZURE_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - - - name: Create Resource Group - run: | - az group create \ - --name ${{ inputs.resource_group }} \ - --location swedencentral - - - name: Deploy Bicep Template - id: deploy - run: | - az deployment group create \ - --resource-group ${{ inputs.resource_group }} \ - --template-file infrastructure/azure/azure-runner.bicep \ - --parameters \ - location=swedencentral \ - adminPassword='${{ inputs.admin_password }}' - - - name: Get Deployment Outputs - id: outputs - run: | - VM_NAME=$(az deployment group show \ - --resource-group ${{ inputs.resource_group }} \ - --name azure-runner \ - --query 'properties.outputs.vmName.value' \ - --output tsv) - VM_PRIVATE_IP=$(az deployment group show \ - --resource-group ${{ inputs.resource_group }} \ - --name azure-runner \ - --query 'properties.outputs.vmPrivateIP.value' \ - --output tsv) - BASTION_NAME=$(az deployment group show \ - --resource-group ${{ inputs.resource_group }} \ - --name azure-runner \ - --query 'properties.outputs.bastionName.value' \ - --output tsv) - echo "vm_name=$VM_NAME" >> $GITHUB_OUTPUT - echo "vm_private_ip=$VM_PRIVATE_IP" >> $GITHUB_OUTPUT - echo "bastion_name=$BASTION_NAME" >> $GITHUB_OUTPUT - - - name: Display Next Steps - run: | - echo "✅ Deployment complete!" - echo "" - echo "📋 Next Steps:" - echo "1. Connect via Azure Bastion:" - echo " - Go to Azure Portal" - echo " - Navigate to VM: ${{ steps.outputs.outputs.vm_name }}" - echo " - Click 'Connect' → 'Bastion'" - echo " - Username: azureuser" - echo " - Password: (the admin_password you provided)" - echo "" - echo "2. Install software manually (in order):" - echo " a) Office 365 Excel (https://portal.office.com)" - echo " b) .NET 10 SDK (https://dotnet.microsoft.com/download)" - echo " c) GitHub Actions Runner:" - echo " - Go to: https://github.com/${{ github.repository }}/settings/actions/runners" - echo " - Click 'New self-hosted runner' → Windows" - echo " - Follow setup commands on VM" - echo " - Use labels: self-hosted, windows, excel" - echo "" - echo "3. Reboot VM after installation" - echo "" - echo "💰 Cost: ~\$200/month (VM \$61 + Bastion Developer \$140)" - echo "" - echo "🔗 VM Private IP: ${{ steps.outputs.outputs.vm_private_ip }}" - echo "🔗 Bastion: ${{ steps.outputs.outputs.bastion_name }}" - echo "" - echo "📚 Full setup guide: infrastructure/azure/GITHUB_ACTIONS_DEPLOYMENT.md" - - - diff --git a/.github/workflows/deploy-gh-pages.yml b/.github/workflows/deploy-gh-pages.yml deleted file mode 100644 index 70e331e6..00000000 --- a/.github/workflows/deploy-gh-pages.yml +++ /dev/null @@ -1,77 +0,0 @@ -name: Deploy GitHub Pages - -on: - push: - branches: - - main - paths: - - 'gh-pages/**' - - 'FEATURES.md' - - 'CHANGELOG.md' - - '.github/workflows/deploy-gh-pages.yml' - workflow_dispatch: - -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages -permissions: - contents: read - pages: write - id-token: write - -# Allow only one concurrent deployment -concurrency: - group: "pages" - cancel-in-progress: false - -jobs: - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: '3.3' - bundler-cache: true - working-directory: gh-pages - - - name: Build with Jekyll - working-directory: gh-pages - run: | - chmod +x build.sh - ./build.sh production - env: - JEKYLL_ENV: production - - - name: Setup Pages - uses: actions/configure-pages@v4 - - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 - with: - path: 'gh-pages/_site' - - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 - - - name: Notify IndexNow (Bing/Yandex) - run: | - # Get list of all HTML pages from the built site (excluding 404.html which should not be indexed) - cd gh-pages/_site - URLS=$(find . -name "*.html" -type f ! -name "404.html" | sed 's|^\./||' | sed 's|index\.html$||' | sed 's|^|https://excelmcpserver.dev/|' | jq -R -s -c 'split("\n") | map(select(length > 0))') - - # Submit to IndexNow API - curl -X POST "https://api.indexnow.org/IndexNow" \ - -H "Content-Type: application/json; charset=utf-8" \ - -d "{ - \"host\": \"excelmcpserver.dev\", - \"key\": \"2049c837880704706739672b0ca752f9\", - \"keyLocation\": \"https://excelmcpserver.dev/2049c837880704706739672b0ca752f9.txt\", - \"urlList\": $URLS - }" \ - --fail-with-body || echo "IndexNow notification failed (non-critical)" diff --git a/.github/workflows/integration-tests.yml.disabled b/.github/workflows/integration-tests.yml.disabled deleted file mode 100644 index b5f7d49d..00000000 --- a/.github/workflows/integration-tests.yml.disabled +++ /dev/null @@ -1,138 +0,0 @@ -name: Integration Tests (Excel) - -# This workflow runs Excel COM integration tests on a self-hosted Azure VM runner -# Requires: Azure Windows VM with Microsoft Excel and GitHub Actions runner installed -# Setup Guide: docs/AZURE_SELFHOSTED_RUNNER_SETUP.md -# -# Trigger Options: -# - Manual: Workflow dispatch from Actions tab -# - PR Optional: Add 'run-integration-tests' label to PR - -permissions: - contents: read - checks: write # Required for test reporter - pull-requests: write # Required for PR comments - -on: - # Allow manual trigger from Actions tab - workflow_dispatch: - - # Optional: Run on PR when specifically requested via label - pull_request: - types: [labeled] - branches: [ main ] - -jobs: - integration-tests: - runs-on: [self-hosted, windows, excel] - timeout-minutes: 90 - # Run on manual dispatch or PR with 'run-integration-tests' label - if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.label.name, 'run-integration-tests')) - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Configure Git safe directory - run: git config --global --add safe.directory ${{ github.workspace }} - shell: pwsh - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 8.0.x - - - name: Display Excel Version - run: | - try { - $excel = New-Object -ComObject Excel.Application - $version = $excel.Version - Write-Output "✅ Excel Version: $version" - $excel.Quit() - [System.Runtime.InteropServices.Marshal]::ReleaseComObject($excel) | Out-Null - [System.GC]::Collect() - [System.GC]::WaitForPendingFinalizers() - } catch { - Write-Error "❌ Excel not available: $_" - exit 1 - } - shell: pwsh - - - name: Restore dependencies - run: dotnet restore - - - name: Build - run: dotnet build --no-restore --configuration Release - - - name: Verify Core Commands Coverage - run: | - Write-Output "🔍 Verifying Core Commands coverage..." - & scripts/audit-core-coverage.ps1 -FailOnGaps - if ($LASTEXITCODE -ne 0) { - Write-Error "❌ Coverage gaps detected! All Core methods must be exposed via MCP Server." - exit 1 - } - Write-Output "✅ Coverage audit passed - 100% coverage maintained" - shell: pwsh - - - name: Run Integration Tests (ComInterop - Foundation) - run: dotnet test tests/ExcelMcp.ComInterop.Tests/ExcelMcp.ComInterop.Tests.csproj --no-build --configuration Release --filter "Category=Integration&RunType!=OnDemand" --logger "trx;LogFileName=cominterop-integration-test-results.trx" --verbosity normal -- RunConfiguration.MaxCpuCount=1 - - - name: Run Integration Tests (Core - Excluding VBA) - run: dotnet test tests/ExcelMcp.Core.Tests/ExcelMcp.Core.Tests.csproj --no-build --configuration Release --filter "Category=Integration&RunType!=OnDemand&Feature!=VBA&Feature!=VBATrust" --logger "trx;LogFileName=core-integration-test-results.trx" --verbosity normal -- RunConfiguration.MaxCpuCount=1 - - - name: Run Integration Tests (MCP Server - Excluding VBA) - run: dotnet test tests/ExcelMcp.McpServer.Tests/ExcelMcp.McpServer.Tests.csproj --no-build --configuration Release --filter "Category=Integration&RunType!=OnDemand&Feature!=VBA&Feature!=VBATrust" --logger "trx;LogFileName=mcp-integration-test-results.trx" --verbosity normal -- RunConfiguration.MaxCpuCount=1 - - - name: Run Integration Tests (CLI - Excluding VBA) - run: dotnet test tests/ExcelMcp.CLI.Tests/ExcelMcp.CLI.Tests.csproj --no-build --configuration Release --filter "Category=Integration&RunType!=OnDemand&Feature!=VBA&Feature!=VBATrust" --logger "trx;LogFileName=cli-integration-test-results.trx" --verbosity normal -- RunConfiguration.MaxCpuCount=1 - - - name: Upload Test Results - if: always() - uses: actions/upload-artifact@v4 - with: - name: integration-test-results-${{ github.run_number }} - path: '**/TestResults/*.trx' - retention-days: 30 - - - name: Publish Test Results - if: always() - uses: dorny/test-reporter@v1 - with: - name: Integration Test Results - path: '**/TestResults/*.trx' - reporter: dotnet-trx - fail-on-error: true - max-annotations: 50 - - - name: Cleanup Excel Processes - if: always() - run: | - Write-Output "🧹 Cleaning up Excel processes..." - $excelProcesses = Get-Process excel -ErrorAction SilentlyContinue - if ($excelProcesses) { - Write-Output "Found $($excelProcesses.Count) Excel process(es) - terminating..." - $excelProcesses | Stop-Process -Force - Start-Sleep -Seconds 5 - Write-Output "✅ Cleanup complete" - } else { - Write-Output "✅ No Excel processes found" - } - - # Force garbage collection - [System.GC]::Collect() - [System.GC]::WaitForPendingFinalizers() - [System.GC]::Collect() - shell: pwsh - - - name: Check for Orphaned Excel Processes - if: always() - run: | - $remainingProcesses = Get-Process excel -ErrorAction SilentlyContinue - if ($remainingProcesses) { - Write-Warning "⚠️ Warning: $($remainingProcesses.Count) Excel process(es) still running after cleanup" - $remainingProcesses | Format-Table Id, ProcessName, StartTime, CPU, WorkingSet -AutoSize - } else { - Write-Output "✅ No orphaned Excel processes detected" - } - shell: pwsh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 243725fc..ca4168e8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,6 @@ name: Release All Components -# Unified release workflow for all ExcelMcp components: +# Unified release workflow for all PptMcp components: # - MCP Server (NuGet + ZIP) - includes CLI as bundled dependency # - CLI (NuGet) - standalone dotnet tool for coding agents # - VS Code Extension (VSIX + Marketplace) @@ -15,7 +15,6 @@ name: Release All Components # Required GitHub Secrets: # - NUGET_USER: Your NuGet.org username (profile name, NOT email) # - VSCE_TOKEN: VS Code Marketplace PAT -# - APPINSIGHTS_CONNECTION_STRING: Application Insights connection string # # npm publishing uses Trusted Publishing (OIDC) — no token needed. # Configure at npmjs.com → Package Settings → Trusted Publisher. @@ -131,19 +130,19 @@ jobs: shell: pwsh - name: Restore - run: dotnet restore src/ExcelMcp.CLI/ExcelMcp.CLI.csproj + run: dotnet restore src/PptMcp.CLI/PptMcp.CLI.csproj - name: Build CLI - run: dotnet build src/ExcelMcp.CLI/ExcelMcp.CLI.csproj --configuration Release --no-restore -p:Version=${{ env.VERSION }} -p:AssemblyVersion=${{ env.VERSION }}.0 -p:FileVersion=${{ env.VERSION }}.0 + run: dotnet build src/PptMcp.CLI/PptMcp.CLI.csproj --configuration Release --no-restore -p:Version=${{ env.VERSION }} -p:AssemblyVersion=${{ env.VERSION }}.0 -p:FileVersion=${{ env.VERSION }}.0 - name: Pack CLI NuGet - run: dotnet pack src/ExcelMcp.CLI/ExcelMcp.CLI.csproj --configuration Release --no-build --output ./cli-nupkg -p:Version=${{ env.VERSION }} + run: dotnet pack src/PptMcp.CLI/PptMcp.CLI.csproj --configuration Release --no-build --output ./cli-nupkg -p:Version=${{ env.VERSION }} - name: Upload CLI Build Output uses: actions/upload-artifact@v4 with: name: cli-build - path: src/ExcelMcp.CLI/bin/Release/net10.0-windows/ + path: src/PptMcp.CLI/bin/Release/net10.0-windows/ - name: Upload CLI NuGet Artifact uses: actions/upload-artifact@v4 @@ -174,7 +173,7 @@ jobs: uses: actions/download-artifact@v4 with: name: cli-build - path: src/ExcelMcp.CLI/bin/Release/net10.0-windows/ + path: src/PptMcp.CLI/bin/Release/net10.0-windows/ - name: Set Version run: | @@ -188,39 +187,37 @@ jobs: $version = $env:VERSION # Update MCP Registry server.json - $serverJsonPath = "src/ExcelMcp.McpServer/.mcp/server.json" + $serverJsonPath = "src/PptMcp.McpServer/.mcp/server.json" $serverContent = Get-Content $serverJsonPath -Raw $serverContent = $serverContent -replace '("version":\s*)"[\d\.]+"(\s*,\s*\n\s*"packages")' , "`$1`"$version`"`$2" - $serverContent = $serverContent -replace '("identifier":\s*"Sbroenne\.ExcelMcp\.McpServer",\s*\n\s*"version":\s*)"[\d\.]+"', "`$1`"$version`"" + $serverContent = $serverContent -replace '("identifier":\s*"Trsdn\.PptMcp\.McpServer",\s*\n\s*"version":\s*)"[\d\.]+"', "`$1`"$version`"" Set-Content $serverJsonPath $serverContent shell: pwsh - name: Restore - run: dotnet restore src/ExcelMcp.McpServer/ExcelMcp.McpServer.csproj + run: dotnet restore src/PptMcp.McpServer/PptMcp.McpServer.csproj - name: Build - run: dotnet build src/ExcelMcp.McpServer/ExcelMcp.McpServer.csproj --configuration Release --no-restore -p:Version=${{ env.VERSION }} -p:AssemblyVersion=${{ env.VERSION }}.0 -p:FileVersion=${{ env.VERSION }}.0 - env: - APPINSIGHTS_CONNECTION_STRING: ${{ secrets.APPINSIGHTS_CONNECTION_STRING }} + run: dotnet build src/PptMcp.McpServer/PptMcp.McpServer.csproj --configuration Release --no-restore -p:Version=${{ env.VERSION }} -p:AssemblyVersion=${{ env.VERSION }}.0 -p:FileVersion=${{ env.VERSION }}.0 - name: Pack NuGet - run: dotnet pack src/ExcelMcp.McpServer/ExcelMcp.McpServer.csproj --configuration Release --no-build --output ./nupkg -p:Version=${{ env.VERSION }} + run: dotnet pack src/PptMcp.McpServer/PptMcp.McpServer.csproj --configuration Release --no-build --output ./nupkg -p:Version=${{ env.VERSION }} - name: Create Release Package run: | $version = $env:VERSION - New-Item -ItemType Directory -Path "release/ExcelMcp-MCP-Server-$version" -Force - Copy-Item "src/ExcelMcp.McpServer/bin/Release/net10.0-windows/*" "release/ExcelMcp-MCP-Server-$version/" -Recurse - Copy-Item "README.md" "release/ExcelMcp-MCP-Server-$version/" - Copy-Item "LICENSE" "release/ExcelMcp-MCP-Server-$version/" - Compress-Archive -Path "release/ExcelMcp-MCP-Server-$version/*" -DestinationPath "ExcelMcp-MCP-Server-$version-windows.zip" + New-Item -ItemType Directory -Path "release/PptMcp-MCP-Server-$version" -Force + Copy-Item "src/PptMcp.McpServer/bin/Release/net10.0-windows/*" "release/PptMcp-MCP-Server-$version/" -Recurse + Copy-Item "README.md" "release/PptMcp-MCP-Server-$version/" + Copy-Item "LICENSE" "release/PptMcp-MCP-Server-$version/" + Compress-Archive -Path "release/PptMcp-MCP-Server-$version/*" -DestinationPath "PptMcp-MCP-Server-$version-windows.zip" shell: pwsh - name: Upload ZIP Artifact uses: actions/upload-artifact@v4 with: name: mcp-server-zip - path: ExcelMcp-MCP-Server-*-windows.zip + path: PptMcp-MCP-Server-*-windows.zip - name: Upload NuGet Artifact uses: actions/upload-artifact@v4 @@ -291,15 +288,13 @@ jobs: run: | cd vscode-extension npm run package - env: - APPINSIGHTS_CONNECTION_STRING: ${{ secrets.APPINSIGHTS_CONNECTION_STRING }} - name: Rename VSIX run: | $version = $env:VERSION cd vscode-extension $vsix = Get-ChildItem -Filter "*.vsix" | Select-Object -First 1 - $targetName = "excelmcp-$version.vsix" + $targetName = "PptMcp-$version.vsix" if ($vsix.Name -ne $targetName) { Rename-Item $vsix.FullName -NewName $targetName } @@ -309,7 +304,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: vscode-vsix - path: vscode-extension/excelmcp-*.vsix + path: vscode-extension/PptMcp-*.vsix # ============================================================================= # Job 4: Build MCPB (Claude Desktop Bundle) @@ -340,14 +335,12 @@ jobs: cd mcpb .\Build-McpBundle.ps1 -Version $env:VERSION shell: pwsh - env: - APPINSIGHTS_CONNECTION_STRING: ${{ secrets.APPINSIGHTS_CONNECTION_STRING }} - name: Upload Artifact uses: actions/upload-artifact@v4 with: name: mcpb-bundle - path: mcpb/artifacts/excel-mcp-*.mcpb + path: mcpb/artifacts/ppt-mcp-*.mcpb # ============================================================================= # Job 5: Build Agent Skills Packages @@ -387,7 +380,7 @@ jobs: New-Item -ItemType Directory -Path $outputDir -Force | Out-Null # Create staging directory - $stagingDir = Join-Path $env:TEMP "excel-skills-$([guid]::NewGuid().ToString('N').Substring(0,8))" + $stagingDir = Join-Path $env:TEMP "ppt-skills-$([guid]::NewGuid().ToString('N').Substring(0,8))" New-Item -ItemType Directory -Path $stagingDir -Force | Out-Null try { @@ -395,11 +388,11 @@ jobs: $skillsStagingDir = Join-Path $stagingDir "skills" New-Item -ItemType Directory -Path $skillsStagingDir -Force | Out-Null - # Copy excel-mcp skill (SKILL.md and references already generated by build) - Copy-Item -Path "$skillsDir/excel-mcp" -Destination "$skillsStagingDir/excel-mcp" -Recurse + # Copy ppt-mcp skill (SKILL.md and references already generated by build) + Copy-Item -Path "$skillsDir/ppt-mcp" -Destination "$skillsStagingDir/ppt-mcp" -Recurse - # Copy excel-cli skill (SKILL.md and references already generated by build) - Copy-Item -Path "$skillsDir/excel-cli" -Destination "$skillsStagingDir/excel-cli" -Recurse + # Copy ppt-cli skill (SKILL.md and references already generated by build) + Copy-Item -Path "$skillsDir/ppt-cli" -Destination "$skillsStagingDir/ppt-cli" -Recurse # Copy skills README to root of package if (Test-Path "$skillsDir/README.md") { @@ -407,7 +400,7 @@ jobs: } # Create ZIP archive - $zipPath = Join-Path $outputDir "excel-skills-v$version.zip" + $zipPath = Join-Path $outputDir "ppt-skills-v$version.zip" Compress-Archive -Path "$stagingDir\*" -DestinationPath $zipPath -CompressionLevel Optimal Write-Host "Created: $zipPath" @@ -425,35 +418,35 @@ jobs: # Generate manifest.json $manifest = @{ - name = "excel-skills" + name = "ppt-skills" version = $version - description = "Excel MCP Server Agent Skills for AI coding assistants" + description = "PowerPoint MCP Server Agent Skills for AI coding assistants" platforms = @("github-copilot", "claude-code", "cursor", "windsurf", "gemini-cli", "goose", "codex", "opencode", "amp", "kilo", "roo", "trae") skills = @( @{ - name = "excel-mcp" - path = "skills/excel-mcp" + name = "ppt-mcp" + path = "skills/ppt-mcp" description = "MCP Server skill - for conversational AI (Claude Desktop, VS Code Chat)" target = "MCP Server" } @{ - name = "excel-cli" - path = "skills/excel-cli" + name = "ppt-cli" + path = "skills/ppt-cli" description = "CLI skill - for coding agents (Copilot, Cursor, Windsurf)" target = "CLI Tool" } ) installation = @{ - npx = "npx skills add sbroenne/mcp-server-excel" - selectSkill = "npx skills add sbroenne/mcp-server-excel --skill excel-cli" - installBoth = "npx skills add sbroenne/mcp-server-excel --skill '*'" + npx = "npx skills add trsdn/mcp-server-ppt" + selectSkill = "npx skills add trsdn/mcp-server-ppt --skill ppt-cli" + installBoth = "npx skills add trsdn/mcp-server-ppt --skill '*'" } files = @( @{ name = "CLAUDE.md"; type = "config"; description = "Claude Code project instructions" } @{ name = ".cursorrules"; type = "config"; description = "Cursor project rules" } ) - repository = "https://github.com/sbroenne/mcp-server-excel" - documentation = "https://excelmcpserver.dev/" + repository = "https://github.com/trsdn/mcp-server-ppt" + documentation = "https://PptMcpserver.dev/" buildDate = (Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ") } $manifest | ConvertTo-Json -Depth 10 | Set-Content -Path "$outputDir/manifest.json" -Encoding UTF8 @@ -464,26 +457,26 @@ jobs: uses: actions/upload-artifact@v4 with: name: agent-skills - path: artifacts/skills/excel-skills-*.zip + path: artifacts/skills/ppt-skills-*.zip - name: Populate npm Skill Packages run: | $version = $env:VERSION - # Populate excel-mcp-skill - $mcpTarget = "packages/excel-mcp-skill/skills/excel-mcp" + # Populate ppt-mcp-skill + $mcpTarget = "packages/ppt-mcp-skill/skills/ppt-mcp" Get-ChildItem $mcpTarget -Exclude ".gitkeep" -Recurse -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force - Copy-Item "skills/excel-mcp/SKILL.md" $mcpTarget - Copy-Item "skills/excel-mcp/references" "$mcpTarget/references" -Recurse -ErrorAction SilentlyContinue + Copy-Item "skills/ppt-mcp/SKILL.md" $mcpTarget + Copy-Item "skills/ppt-mcp/references" "$mcpTarget/references" -Recurse -ErrorAction SilentlyContinue - # Populate excel-cli-skill - $cliTarget = "packages/excel-cli-skill/skills/excel-cli" + # Populate ppt-cli-skill + $cliTarget = "packages/ppt-cli-skill/skills/ppt-cli" Get-ChildItem $cliTarget -Exclude ".gitkeep" -Recurse -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force - Copy-Item "skills/excel-cli/SKILL.md" $cliTarget - Copy-Item "skills/excel-cli/references" "$cliTarget/references" -Recurse -ErrorAction SilentlyContinue + Copy-Item "skills/ppt-cli/SKILL.md" $cliTarget + Copy-Item "skills/ppt-cli/references" "$cliTarget/references" -Recurse -ErrorAction SilentlyContinue # Update versions in package.json files - foreach ($pkg in @("packages/excel-mcp-skill/package.json", "packages/excel-cli-skill/package.json")) { + foreach ($pkg in @("packages/ppt-mcp-skill/package.json", "packages/ppt-cli-skill/package.json")) { $content = Get-Content $pkg -Raw $content = $content -replace '"version":\s*"[\d\.]+"', "`"version`": `"$version`"" Set-Content $pkg $content @@ -520,17 +513,17 @@ jobs: - name: Update server.json Version run: | $version = $env:VERSION - $serverJsonPath = "src/ExcelMcp.McpServer/.mcp/server.json" + $serverJsonPath = "src/PptMcp.McpServer/.mcp/server.json" $serverContent = Get-Content $serverJsonPath -Raw $serverContent = $serverContent -replace '("version":\s*)"[\d\.]+"(\s*,\s*\n\s*"packages")' , "`$1`"$version`"`$2" - $serverContent = $serverContent -replace '("identifier":\s*"Sbroenne\.ExcelMcp\.McpServer",\s*\n\s*"version":\s*)"[\d\.]+"', "`$1`"$version`"" + $serverContent = $serverContent -replace '("identifier":\s*"Trsdn\.PptMcp\.McpServer",\s*\n\s*"version":\s*)"[\d\.]+"', "`$1`"$version`"" Set-Content $serverJsonPath $serverContent shell: pwsh - name: Wait for NuGet Propagation run: | $version = $env:VERSION - $packageId = "sbroenne.excelmcp.mcpserver" + $packageId = "PptMcp.mcpserver" $readmeUrl = "https://api.nuget.org/v3-flatcontainer/$packageId/$version/readme" Write-Output "Waiting for NuGet CDN propagation..." @@ -543,7 +536,7 @@ jobs: $response = Invoke-WebRequest -Uri $readmeUrl -UseBasicParsing -ErrorAction Stop if ($response.StatusCode -eq 200) { $content = [System.Text.Encoding]::UTF8.GetString($response.Content) - if ($content -match "mcp-name:\s+io\.github\.sbroenne/mcp-server-excel") { + if ($content -match "mcp-name:\s+io\.github\.trsdn/mcp-server-ppt") { Write-Output "README propagated successfully" break } @@ -567,7 +560,7 @@ jobs: - name: Publish to MCP Registry run: | ./mcp-publisher.exe login github-oidc - Set-Location src/ExcelMcp.McpServer/.mcp + Set-Location src/PptMcp.McpServer/.mcp ../../../mcp-publisher.exe publish --verbose shell: pwsh continue-on-error: true @@ -661,17 +654,17 @@ jobs: - name: Publish to NuGet.org run: | $version = $env:VERSION - dotnet nuget push "nupkg/Sbroenne.ExcelMcp.McpServer.$version.nupkg" ` + dotnet nuget push "nupkg/PptMcp.McpServer.$version.nupkg" ` --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }} ` --source https://api.nuget.org/v3/index.json ` --skip-duplicate - Write-Output "Published Sbroenne.ExcelMcp.McpServer.$version to NuGet.org" + Write-Output "Published PptMcp.McpServer.$version to NuGet.org" - dotnet nuget push "cli-nupkg/Sbroenne.ExcelMcp.CLI.$version.nupkg" ` + dotnet nuget push "cli-nupkg/PptMcp.CLI.$version.nupkg" ` --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }} ` --source https://api.nuget.org/v3/index.json ` --skip-duplicate - Write-Output "Published Sbroenne.ExcelMcp.CLI.$version to NuGet.org" + Write-Output "Published PptMcp.CLI.$version to NuGet.org" shell: pwsh - name: Publish to VS Code Marketplace @@ -679,7 +672,7 @@ jobs: with: pat: ${{ secrets.VSCE_TOKEN }} registryUrl: https://marketplace.visualstudio.com - extensionFile: vscode-vsix/excelmcp-${{ env.VERSION }}.vsix + extensionFile: vscode-vsix/PptMcp-${{ env.VERSION }}.vsix continue-on-error: true - name: Setup npm registry @@ -688,15 +681,15 @@ jobs: node-version: ${{ env.NODE_VERSION }} registry-url: https://registry.npmjs.org - - name: Publish excel-mcp-skill to npm + - name: Publish ppt-mcp-skill to npm run: | - cd npm-packages/excel-mcp-skill + cd npm-packages/ppt-mcp-skill npm publish --provenance --access public continue-on-error: true - - name: Publish excel-cli-skill to npm + - name: Publish ppt-cli-skill to npm run: | - cd npm-packages/excel-cli-skill + cd npm-packages/ppt-cli-skill npm publish --provenance --access public continue-on-error: true @@ -756,7 +749,7 @@ jobs: # Build release notes cat > release_notes.md << 'NOTES' - ## ExcelMcp ${{ env.VERSION }} + ## PptMcp ${{ env.VERSION }} ### What's New ${{ steps.changelog.outputs.CHANGELOG }} @@ -764,37 +757,37 @@ jobs: ### Installation Options **VS Code Extension** (Recommended) - - Search "ExcelMcp" in VS Code Marketplace and click Install - - Or download `excelmcp-${{ env.VERSION }}.vsix` below + - Search "PptMcp" in VS Code Marketplace and click Install + - Or download `PptMcp-${{ env.VERSION }}.vsix` below - Self-contained: no .NET runtime or SDK required - - Includes both MCP Server and CLI (`excelcli`) - - Agent skills (excel-mcp + excel-cli) registered automatically via `chatSkills` + - Includes both MCP Server and CLI (`pptcli`) + - Agent skills (ppt-mcp + ppt-cli) registered automatically via `chatSkills` **Claude Desktop (MCPB)** - - Download `excel-mcp-${{ env.VERSION }}.mcpb` and double-click to install + - Download `ppt-mcp-${{ env.VERSION }}.mcpb` and double-click to install **NuGet (.NET Tool)** ```powershell # MCP Server (for AI assistants) - dotnet tool install --global Sbroenne.ExcelMcp.McpServer + dotnet tool install --global PptMcp.McpServer # CLI (for coding agents and scripting) - dotnet tool install --global Sbroenne.ExcelMcp.CLI + dotnet tool install --global PptMcp.CLI ``` **Agent Skills** (for AI coding assistants) - - VS Code Extension includes both skills automatically (excel-mcp + excel-cli) - - Install via npm: `npx skillpm install excel-mcp-skill` or `npx skillpm install excel-cli-skill` - - Or download `excel-skills-v${{ env.VERSION }}.zip` or install via `npx skills add sbroenne/mcp-server-excel` + - VS Code Extension includes both skills automatically (ppt-mcp + ppt-cli) + - Install via npm: `npx skillpm install ppt-mcp-skill` or `npx skillpm install ppt-cli-skill` + - Or download `ppt-skills-v${{ env.VERSION }}.zip` or install via `npx skills add trsdn/mcp-server-ppt` ### Requirements - Windows OS - - Microsoft Excel 2016+ + - Microsoft PowerPoint 2016+ - .NET 10 Runtime or SDK (for NuGet installation only; VS Code and MCPB bundle it) ### Documentation - - [Website](https://excelmcpserver.dev/) - - [GitHub Repository](https://github.com/sbroenne/mcp-server-excel) - - [Changelog](https://github.com/sbroenne/mcp-server-excel/blob/main/CHANGELOG.md) + - [Website](https://PptMcpserver.dev/) + - [GitHub Repository](https://github.com/trsdn/mcp-server-ppt) + - [Changelog](https://github.com/trsdn/mcp-server-ppt/blob/main/CHANGELOG.md) NOTES # Collect all artifacts @@ -808,7 +801,7 @@ jobs: echo "Creating release with artifacts:$ARTIFACTS" gh release create "$TAG" $ARTIFACTS \ - --title "ExcelMcp $VERSION" \ + --title "PptMcp $VERSION" \ --notes-file release_notes.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index e1c5e0d6..05e3118f 100644 --- a/.gitignore +++ b/.gitignore @@ -209,7 +209,7 @@ FodyWeavers.xsd .idea/ *.sln.iml -# ExcelCLI specific ignores - test files +# PptCLI specific ignores - test files *.xlsx *.xls *.pq @@ -218,13 +218,13 @@ temp/ sample_files/ # Exception: Allow Data Model template (required for tests) -!tests/ExcelMcp.Core.Tests/TestAssets/DataModelTemplate.xlsx +!tests/PptMcp.Core.Tests/TestAssets/DataModelTemplate.xlsx # Exception: Allow ComInterop static test file (required for batch tests) -!tests/ExcelMcp.ComInterop.Tests/Integration/Session/TestFiles/batch-test-static.xlsx +!tests/PptMcp.ComInterop.Tests/Integration/Session/TestFiles/batch-test-static.pptx # LOCAL-ONLY diagnostic tests (reference external workbooks with confidential data) -tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.ExternalWorkbook.cs +tests/PptMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.ExternalWorkbook.cs codeql-db/* infrastructure/azure/appinsights.secrets.local diff --git a/CHANGELOG.md b/CHANGELOG.md index 110f4939..1111d25e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,563 +1,47 @@ # Changelog -All notable changes to ExcelMcp will be documented in this file. +All notable changes to PptMcp (PowerPoint MCP Server) will be documented in this file. -This changelog covers all components: -- **MCP Server** - Model Context Protocol server for AI assistants -- **CLI** - Command-line interface for scripting and coding agents -- **VS Code Extension** - One-click installation with bundled MCP Server -- **MCPB** - Claude Desktop bundle for one-click installation +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] -### Fixed - -- **`range set-formulas` and `range get-formulas` injected `@` implicit intersection operator inside Excel Tables**: The legacy `Range.Formula` COM property automatically prepends `@` to formulas inside structured tables, causing `#FIELD!` errors with custom functions that return entity cards (e.g., Office Add-in rich data types). Switched to `Range.Formula2` (Excel 365+) which respects dynamic array semantics and does not inject `@`. - -- **Connection `refresh` and PowerQuery `refresh` / `refresh-all` could hang or miss cancellation on async data sources**: `WorkbookConnection.Refresh()` returns immediately when the provider runs asynchronously, leaving the STA thread without a way to detect completion or honour the operation timeout. Both Connection and PowerQuery refresh now set the sub-connection's `BackgroundQuery = true`, call `Refresh()`, then poll `.Refreshing` in a loop that responds to cancellation and calls `.CancelRefresh()` when the timeout fires. `powerquery refresh-all` was also updated to use the same robust `RefreshConnectionByQueryName` path (which includes `QueryTable.Refresh(false)` for worksheet queries) instead of a bare `connection.Refresh()`. - -- **CLI and MCP Server version always reported as 1.0.0** (#523): The update check and About dialog always showed version 1.0.0 instead of the actual installed version. Fixed by removing hardcoded version properties from project files so they inherit from the central version configuration. - -- **`table append` JsonElement COM marshalling** (#519): Row values containing booleans or strings were passed as raw `System.Text.Json.JsonElement` to `cell.Value2`, which COM interop cannot marshal to a Variant. Fixed by calling `RangeHelpers.ConvertToCellValue()` (the same fix already present in `range set-values`) to unwrap `JsonElement` to native types before assignment. - -- **`--values`/`--rows` inline JSON: PowerShell quote-stripping + stdin sentinel** (#521): Windows `CreateProcess` strips inner double-quotes when PowerShell passes arguments to native executables, so `--values '[["ACD Full Term",0.26]]'` arrives as `[[ACD Full Term,0.26]]` (invalid JSON). The generated `DeserializeNestedCollection` now: (1) emits a clear error message that mentions `--values-file` and `--values -` as workarounds, and (2) supports a stdin sentinel — passing `--values -` (or `--rows -`) reads the JSON from `Console.In`, avoiding shell quoting entirely. - -- **Table `add-to-data-model` bracket column names block DAX formulas**: Excel table columns with literal bracket characters in their names (e.g., from OLEDB import sources) cannot be referenced in DAX formulas after being added to the Data Model. Added new `stripBracketColumnNames` parameter (default: `false`). When `false`, bracket column names are reported in `bracketColumnsFound` so users are aware of the issue. When `true`, the source table column headers are renamed (brackets removed) before adding to the Data Model, enabling full DAX access. The `add-to-data-model` result now includes `bracketColumnsFound` and `bracketColumnsRenamed` fields. - -- **PowerQuery `load-to data-model` silently succeeded without loading data**: `powerquery load-to` with `data-model` destination returned `success: true` but the table never appeared in the Power Pivot Data Model. The connection was registered via `Connections.Add2()` but `connection.Refresh()` was never called, so data was not actually loaded. Fixed by calling `connection.Refresh()` after creating the connection, consistent with how `load-to worksheet` works. - -- **`chartconfig set-data-labels` threw raw COMException on Line charts with bar-only position**: Setting `labelPosition` to `InsideEnd`, `InsideBase`, or `OutsideEnd` on a Line chart threw a raw COM exception with no user-friendly explanation. These positions are only valid for bar, column, and area chart types. Fixed by catching the COMException and throwing an `InvalidOperationException` with a descriptive message explaining which chart types support each position, consistent with how `ShowPercentage` handles unsupported chart types. - -- **`rangeformat format-range` parameter documentation listed wrong valid values for `borderStyle`**: The `borderStyle` parameter help incorrectly listed `thin`, `medium`, `thick`, `dashed`, and `dotted` as valid values — those are `borderWeight` values. The valid `borderStyle` values are `continuous`, `dash`, `dot`, `dashdot`, `dashdotdot`, `double`, `slantdashdot`, and `none`. Documentation corrected. - -- **`rangeformat format-range` rejected `middle` as a vertical alignment value**: The `verticalAlignment` parameter only accepted `center` but not the common alias `middle`. Both now accepted and produce identical center-vertical alignment. - -### Changed - -- **`screenshot` CLI `--output` flag documentation clarified**: The `--output ` flag saves the screenshot directly to a PNG or JPEG file instead of printing base64 JSON to stdout. This was already functional but was documented as "For CLI: saved to file" without explaining that `--output` is required to save to a file. - -- **office.dll not found when opening workbooks with connections/data model** (#487 follow-up): The `AssemblyResolve` handler only searched `AppContext.BaseDirectory` for `office.dll`. In NuGet-installed tool deployments, `office.dll` is never copied there (it is only present in local dev builds via `Directory.Build.targets`). Opening workbooks with external connections, Power Query, or a Data Model triggered code paths that caused the CLR to load `Microsoft.Office.Interop.Excel.dll`, which in turn requested `office.dll v16`. The handler returned `null` → `FileNotFoundException`. Fixed by adding fallback search order: (1) `AppContext.BaseDirectory`, (2) .NET Framework GAC v16, (3) GAC v15 (accepted by CLR as substitute), (4) Office 365 click-to-run installation directories. `Directory.Build.targets` also updated to prefer v16 GAC when available. - -### Changed - -- **Migrated Excel COM interop to strongly-typed Microsoft Office PIA**: Replaced dynamic late-binding throughout the codebase with strongly-typed `Microsoft.Office.Interop.Excel` types for improved reliability and compile-time error detection. Power Query APIs (`Workbook.Queries`) and VBA project access remain as dynamic calls where PIA coverage is unavailable. - -### Fixed - -- **All Excel sessions crashed with FileNotFoundException for office.dll** (#487): After PIA migration, `ExcelBatch` STA thread declared `tempExcel` as typed `Excel.Application`. Casting a typed COM interop object to `(dynamic)` retains PIA type metadata; the DLR then resolved `MsoAutomationSecurity` from `office.dll` (Microsoft.Office.Core v16.0.0.0) at runtime, which is not bundled with the deployed .NET tool. Every session (create and open) crashed before opening any workbook. Fixed by casting to `(object)` first before `(dynamic)` to force pure IDispatch binding. Also removed a broken `` to office.dll with a wrong v15.0.0.0 hint path (runtime required v16.0.0.0). - -- **STA Deadlock on Conditional Formatting and Other Re-entrant COM Operations**: `OleMessageFilter.MessagePending` was returning `2` (`PENDINGMSG_WAITNOPROCESS`) instead of `1` (`PENDINGMSG_WAITDEFPROCESS`). When Excel fires a re-entrant callback (e.g. `Calculate`/`SheetChange` event) during a `FormatConditions.Add()` call, `WAITNOPROCESS` blocked COM from delivering the callback — Excel waited for the callback while the STA thread waited for Excel, causing a permanent deadlock. Any operation that triggers Excel's internal event loop (conditional formatting on formula cells, PivotTable refresh, Power Query refresh) was affected. Fixed by returning `1` so COM delivers pending inbound calls during the outgoing `IDispatch.Invoke`. -- **Hung Session After Tool Call Cancellation**: When a user cancelled a tool call while the STA thread was stuck in `IDispatch.Invoke`, `WithSessionAsync` had no `catch (OperationCanceledException)` handler — the session remained alive with a permanently blocked STA thread, causing all subsequent operations to hang. Fixed by adding `catch (OperationCanceledException)` that force-closes the session (same pattern as the existing `TimeoutException` handler). -- **Slow Fail on Successive Calls After Timeout/Cancellation**: After a timeout or cancellation, `Execute` would queue new work on a permanently stuck STA thread, forcing each subsequent caller to wait for its own full timeout before failing. Fixed by adding a fail-fast pre-check: if `_operationTimedOut` is set, throw `TimeoutException` immediately. - -- **COM Apartment Boundary in SaveWorkbook** (#482): Removed `Task.Run(() => workbook.Save())` in `ExcelShutdownService` — this marshalled the COM call from the STA thread to an MTA thread-pool thread, which is incorrect and fragile in .NET 8+. Save is now called directly on the STA thread, which is always the case inside `ExcelBatch.Execute()`. -- **Wrong-Process Force-Kill from Fallback PID** (#482): Removed the "newest EXCEL.EXE process" fallback PID detection in `ExcelBatch`. When the `Hwnd` path fails, force-kill is now disabled with a warning rather than risking killing an unrelated Excel workbook the user has open. -- **Redundant `Thread.Sleep` in Dispose** (#482): Removed 100 ms `Thread.Sleep` from `ExcelBatch.Dispose()`. The preceding `_shutdownCts.Cancel()` call immediately wakes the STA thread from `WaitToReadAsync`, making the sleep redundant and adding unnecessary latency. -- **Exception Type Lost in Service Error Responses** (#482): `ExcelMcpService` top-level `catch` blocks now return `"{ExType}: {ex.Message}"` instead of just `ex.Message`, making unexpected failures distinguishable without a full stack trace. -- **COM Timeout Hang** — ExcelBatch now force-kills Excel process on timeout instead of hanging indefinitely on `WaitForSingleObject`; ExcelMcpService catches `TimeoutException` to prevent unhandled exceptions -- **FileSystemWatcher CPU Spin** — Disabled `IConfiguration` reload-on-change in MCP Server to prevent 85%+ CPU usage from `FileSystemWatcher` polling -- **Process Handle Leak** — Fixed `Process` object not being disposed in `ExcelBatch.ForceKillExcelProcess()` -- **Configuration Sources Cleared** — Re-add environment variables and command-line args after clearing config sources (were accidentally removed) -- **Source Generator Type Aggregation** — Fixed nullable type upgrade logic in `ServiceInfoExtractor` that could lose type information across partial interfaces -- **Chart Trendline Parameter Name** — Renamed `type` → `trendlineType` in `IChartConfigCommands` to avoid COM parameter ambiguity -- **Chart Style Error Message** — Improved `SetStyle` error message to show valid range when `styleId` is out of bounds -- **Chart InvalidOperationException** — Added catch for `InvalidOperationException` in chart appearance commands - -### Changed - -- **Chart Test Performance** — Refactored 80 chart tests to share a single pre-populated fixture file via `File.Copy()` instead of creating individual files via COM, eliminating ~74 redundant Excel sessions - -### Added - -- **Screenshot quality parameter**: New `quality` parameter on screenshot tool (`High`/`Medium`/`Low`). Default is `Medium` (JPEG at 75% scale, ~4–8x smaller than original PNG). Use `High` (PNG, full scale) when fine text needs careful inspection, `Low` (JPEG at 50% scale) for layout overviews. -- **Window Management Tool** (#470): New `window` tool with 9 operations to control Excel window visibility, position, state, and status bar — enabling "Agent Mode" where users watch AI work in Excel - - `show` / `hide` — Toggle Excel visibility (syncs with session metadata) - - `bring-to-front` — Bring Excel to foreground - - `get-info` — Query window state (visibility, position, size, foreground status) - - `set-state` — Set normal / minimized / maximized - - `set-position` — Set window left, top, width, height - - `arrange` — Preset layouts: left-half, right-half, top-half, bottom-half, center, full-screen - - `set-status-bar` — Display live operation status text in Excel's status bar - - `clear-status-bar` — Restore default status bar text - - MCP Server proactively asks users about showing Excel for visual tasks (charts, dashboards) - - Agent Mode, Presentation Mode, and Debug Mode workflow guidance -- **CLI `--output` flag** for all commands: Save command output directly to a file. Screenshot commands automatically save decoded PNG images instead of base64 JSON -- **CLI Batch Mode** (#463): New `excelcli batch` command executes multiple CLI commands from a JSON file in a single process launch - - Session auto-capture from `session.open`/`session.create`, auto-clear on `session.close` - - NDJSON output for machine-readable results - - `--stop-on-error` flag to halt on first failure (default: continue all) - -### Fixed - -- **Screenshot reliability**: Screenshots now work reliably regardless of whether Excel is visible or hidden. Added automatic retry for transient capture failures -- **CLI `--help` crash** (#463): Fixed Spectre.Console markup crash when parameter descriptions contain `[`/`]` characters (e.g., `[A1 notation]`) -- **Source generator tool filtering**: Fixed `mcpTool ?? "unknown"` fallback; added `HasMcpToolAttribute` to correctly filter MCP-only tools -- **Skills docs parameter names**: Fixed wrong CLI parameter names in `conditionalformat.md` and `slicer.md` reference files -- **Auto-save on shutdown**: Sessions are now auto-saved before closing when MCP server exits or client disconnects, preventing silent data loss from session timeouts -- **Session creation resilience**: Added retry logic (Polly) for transient COM failures (`CO_E_SERVER_EXEC_FAILURE`, `RPC_E_CALL_FAILED`) during Excel process startup under resource constraints - -## [1.7.2] - 2026-02-15 - -### Added - -- **In-Process Service Architecture** (#454): MCP Server and CLI each host ExcelMCP Service in-process instead of sharing a separate service process - - Eliminates service discovery failures (especially NuGet tool installs) and cross-process coordination - -- **Separate CLI NuGet Package** (#452): CLI published as `Sbroenne.ExcelMcp.CLI` alongside MCP Server - - Service version negotiation: client validates exact version match with running service on connect - -### Fixed - -- **Build Workflow Path** (#455): Fixed target framework path (`net10.0` → `net10.0-windows`) and formatting errors in build workflow - -## [1.7.1] - 2026-02-09 - -### Fixed - -- **Release Workflow** (#451): Moved all external publishing steps after builds succeed to prevent partial releases - -## [1.6.10] - 2026-02-06 - -### ⚠️ BREAKING CHANGES - -**See [BREAKING-CHANGES.md](docs/BREAKING-CHANGES.md) for complete migration guide.** -LLMs pick up these changes automatically via `tools/list` (MCP) and `--help` (CLI). - -- **Tool Names Simplified**: Removed `excel_` prefix from all 23 MCP tool names (e.g., `excel_range` → `range`, `excel_file` → `file`). Titles also shortened (e.g., `"Chart Operations"`). VS Code extension server name → `excel-mcp`. - -### Added - -- **CLI Code Generation** (#433): CLI commands auto-generated from Core via Roslyn source generators — guarantees 1:1 MCP/CLI parity -- **Calculation Mode Control** (#430): New `calculation_mode` tool/CLI command (automatic, manual, semi-automatic modes; workbook/sheet/range scopes) -- **Installation via npx** (#449): Added `npx add-mcp` as primary installation method in docs - -### Changed - -- **MCP Prompt Reduction** (#442): Reduced prompts from 7 to 4 with ~76% content reduction; removed `excel_` prefix from prompt names -- **VS Code Extension**: Self-contained publishing (no .NET runtime needed), CLI removed from extension, skills use `chatSkills` contribution point -- **LLM Tests** (#446): Migrated to pytest-aitest v0.3.x from PyPI with unified MCP/CLI test suite -- **Release Workflow** (#443): Switched to workflow_dispatch with version bump UI; added stale issue workflow -- **Terminology**: "Daemon" → "ExcelMCP Service" throughout docs -- **MCP SKILL template** (#448): Added Workflow Checklist table for quick reference (open → create → write → format → save) -- **CLI SKILL template** (#448): Added "List Parameters Use JSON Arrays" to Common Pitfalls section -- **Slicer reference doc**: Added CLI JSON Array Quoting section with PowerShell escaping examples -- **MCPB**: Removed agent skills from Claude Desktop bundle - -### Fixed - -- **MCP Server Release Path** (#450): Corrected package path to `net10.0-windows` -- **Broken Emoji Characters**: Fixed corrupted emoji in README files - -### Removed - -- **Glama.ai Support**: Removed Docker-based deployment (`Dockerfile`, `glama.json`, `.dockerignore`, docs) - -## [1.6.9] - 2026-02-04 - -### Added - -- **CLI Daemon Improvements**: Enhanced tray icon experience with better update management and save prompts - - Added "Update CLI" menu option when updates are available (detects global vs local .NET tool install) - - Added save dialog (Yes/No/Cancel) when closing individual sessions from tray - - Added save dialog (Yes/No/Cancel) when stopping daemon with active sessions - - Removed redundant disabled "Excel CLI Daemon" status menu entry - - Toast notifications now mention the Update CLI menu option for easier access - - Update command shows in confirmation dialog before execution - - Auto-restart daemon after successful update - -### Fixed - -- **PivotTable RPC Disconnection** (#426): Fixed "RPC server is unavailable (0x800706BA)" error during rapid OLAP PivotTable field operations - - ROOT CAUSE: `RefreshTable()` called after each field operation triggered synchronous Analysis Services queries - - FIX: Removed RefreshTable() from field manipulation methods (AddRowField, AddColumnField, AddFilterField, RemoveField, SetFieldFunction) - - Field changes now take effect immediately without blocking AS queries - - Call `pivottable(refresh)` explicitly to update visual display after configuring fields - - Applies to both OLAP (Data Model) and regular PivotTables for consistency - -## [1.6.8] - 2026-02-03 - -### Changed - -- **JSON Property Names Reverted** (#417): Removed short property name mappings for better readability - - JSON output now uses camelCase C# property names (e.g., `success`, `errorMessage`, `filePath`) - - Removed 433 `[JsonPropertyName]` attributes from model files - - LLMs and humans can now read JSON without consulting a mapping table - -### Fixed - -- **CLI Banner Cleanup**: Removed PowerShell warning from startup banner - - Guidance moved to skill documentation (Rule 2: Use File-Based Input) - - CLI output is now cleaner and less cluttered - -- **CLI Missing Parameter Mappings** (#423): Fixed CLI commands silently ignoring user-provided values - - ROOT CAUSE: Settings properties defined but not passed to daemon in args switch statements - - FIX: Added missing parameter mappings for affected commands: - - `connection set-properties`: Added `description`, `backgroundQuery`, `savePassword`, `refreshPeriod` - - `powerquery create/load-to`: Added `targetSheet`, `targetCellAddress` - - `chart create-*` and `move`: Added `left`, `top`, `width`, `height` - - `table append`: Fixed to parse CSV into proper `rows` format - - `vba run`: Added `timeoutSeconds` - - Added pre-commit check (`check-cli-settings-usage.ps1`) to prevent future occurrences - -## [1.6.5] - 2026-02-03 - -- **Dead Session Detection** (#414): Auto-detect and cleanup sessions when Excel process dies - - ROOT CAUSE: `SessionManager` never checked if Excel process was alive, leaving dead sessions in dictionary - - FIX: `GetSession()`, `GetActiveSessions()`, and `IsSessionAlive()` now check process health and auto-cleanup - - `ExcelBatch.Execute()` validates Excel is alive before queueing operations - - Users now get clear error: "Excel process is no longer running" instead of confusing timeouts - - Dead sessions no longer block reopening the same file - - Affects both CLI and MCP Server (shared `SessionManager`) - -## [1.6.4] - 2026-02-03 - -### Fixed - -- **COM Timeout with Data Model Dependencies** (#412): Fixed timeout when setting formulas/values that trigger Data Model recalculation - - ROOT CAUSE: Excel's automatic calculation blocks COM interface during DAX recalculation - - FIX: Temporarily disable calculation mode (xlCalculationManual) during write operations - - Affected methods: `SetFormulas`, `SetValues`, `Table.Append`, `NamedRange.Write` - - Formulas like `=INDEX(KPIs[Total_ACR],1)` now work without "The operation was canceled" error - -## [1.6.3] - 2026-02-03 - -### Documentation - -- **M Code Identifier Quoting** (#407): Added guidance for special characters in Power Query identifiers -- **PowerQuery Eval-First Workflow** (#405): Updated documentation with eval-first pattern -- **CLI Command Name Fix** (#403): Fixed CLI command name in agent skills installation docs - -## [1.6.2] - 2026-02-02 - -### Fixed - -- **Power Query Refresh Error Propagation** (#399): Fixed bug where `refresh` action returned `success: true` even when Power Query had formula errors - - ROOT CAUSE: `Connection.Refresh()` silently swallows errors for worksheet queries (InModel=false) - - FIX: Now uses `QueryTable.Refresh(false)` for worksheet queries which properly throws errors - - Data Model queries (InModel=true) continue using `Connection.Refresh()` which does throw errors - - Errors now surface clearly: `"[Expression.Error] The name 'Source' wasn't recognized..."` - -- **Table Create Auto-Expand from Single Cell**: Fixed issue where `table create --range A1` created single-cell table - - ROOT CAUSE: Excel's `ListObjects.Add()` doesn't auto-expand from a single cell - - FIX: Now uses `Range.CurrentRegion` when single cell provided, capturing all contiguous data - - Prevents Data Model issues where tables only contain header column - -### Added - -- **Power Query Evaluate** (#400): New `evaluate` action to execute M code directly and return results - - Execute arbitrary M code without creating a permanent query - - Returns tabular results (columns, rows) in JSON format - - Automatically cleans up temporary query and worksheet - - Errors propagate properly (e.g., invalid M syntax throws with error message) - - Example: `excelcli powerquery evaluate --file data.xlsx --mcode "let Source = #table({\"Name\",...})"` - -- **MCP Power Query mCodeFile Parameter**: Read M code from file instead of inline string - - New `mCodeFile` parameter on `powerquery` tool for `create`, `update`, `evaluate` actions - - Avoids JSON escaping issues with complex M code containing special characters - - File takes precedence if both `mCode` and `mCodeFile` provided - -- **MCP VBA vbaCodeFile Parameter**: Read VBA code from file instead of inline string - - New `vbaCodeFile` parameter on `vba` tool for `create-module`, `update-module` actions - - Handles VBA code with quotes and special characters cleanly - - File takes precedence if both `vbaCode` and `vbaCodeFile` provided - -## [1.6.1] - 2026-02-01 - -### Fixed - -- **CLI PackAsTool Workaround** (#396): Fixed CLI packaging issue with net10.0-windows target -- **CI Duplicate Paths** (#394): Removed duplicate paths key in build workflow - -## [1.6.0] - 2026-02-01 - -### Fixed - -- **MCPB Skills Key** (#392): Removed unsupported 'skills' key from manifest -- **Data Model MSOLAP Error** (#391): Better error message when MSOLAP provider is missing - -## [1.5.14] - 2025-02-01 - -### Added - -#### CLI Redesign (Breaking Change) -- **Complete CLI Rewrite** (#387): Redesigned CLI for coding agents and scripting - **NOT backwards compatible** - - 14 unified command categories with 210 operations matching MCP Server - - All commands now use `--session` parameter (was positional in some commands) - - Comprehensive `--help` descriptions on all commands synced with MCP tool descriptions - - All `--file` parameters support both new file creation and existing files - - New `excelcli list-actions` command to discover all available operations - - Exit code standardization (0=success, 1=error, 2=validation) - -- **Quiet Mode**: `-q`/`--quiet` flag suppresses banner for agent-friendly JSON-only output - - Auto-detects piped/redirected stdout and suppresses banner automatically - -- **Version Check**: `excelcli version --check` queries NuGet to show if update available - -- **Session Close --save**: Single `--save` flag for atomic save-and-close workflow - - Replaces separate save + close sequence for cleaner scripting - -- **CLI Action Coverage Pre-commit Check**: New `check-cli-action-coverage.ps1` script - - Ensures CLI switch statements cover ALL action strings from ActionExtensions.cs - - Prevents "action not handled" bugs from reaching production - - Validates 210 operations across 21 CLI commands - -#### MCP Server Enhancements -- **Session Operation Timeout** (#388): Configurable timeout prevents infinite hangs - - New `timeoutSeconds` parameter on `file(open)` and `file(create)` actions - - Default: 300 seconds (5 minutes), configurable range: 10-3600 seconds - - Applies to ALL operations within session; exceeding timeout throws `TimeoutException` - -- **Create Action** (#385): Renamed `create-and-open` to simpler `create` action - - Single-action file creation and session opening - - Performance: ~3.8 seconds (vs ~7-8 seconds with separate create+open) - -- **PowerQuery Unload Action**: New `unload` action removes data from all load destinations - - Keeps query definition intact while clearing worksheet/model data - -#### Testing & Quality -- **LLM Integration Tests**: Comprehensive pytest-aitest test suite for CLI - - 9 test scenarios covering all major Excel operations - - Chart positioning, PivotTable layout, Power Query, slicers, tables, ranges - - Financial report automation workflow tests - -- **Agent Skills**: New structured skills documentation for AI assistants - - `skills/excel-cli/` - CLI-specific skill with commands reference - - `skills/excel-mcp/` - MCP Server skill with tools reference - - `skills/shared/` - Shared workflows, anti-patterns, behavioral rules - -### Fixed -- **Calculated Field Bug**: Fixed PivotTable calculated field creation error -- **COM Diagnostics**: Improved error reporting for COM object lifecycle issues - -### Changed -- CLI timeout option uses `--timeout ` (was `--timeout-seconds`) -- All CLI commands now require explicit `--session` parameter - -## [1.5.13] - 2025-01-24 - ### Added -- **Chart Formatting** (#384): Enhanced chart formatting capabilities - - **Data Labels**: Configure label position and visibility (showValue, showCategory, showPercentage, etc.) - - **Axis Scale**: Get/set axis scale properties (min, max, units, auto-scale flags) - - **Gridlines**: Control major/minor gridlines visibility on chart axes - - **Series Markers**: Configure marker style, size, and colors for data series - - 8 new operations bringing total chart operations to 22 - -- **Chart Trendlines** (#386): Statistical analysis and forecasting for chart series - - **Add Trendline**: Linear, Exponential, Logarithmic, Polynomial, Power, Moving Average - - **List Trendlines**: View all trendlines on a series - - **Delete Trendline**: Remove trendline by index - - **Configure Trendline**: Forward/backward forecasting, display equation and R² value - - 4 new operations bringing total chart operations to 26 - -## [1.5.11] - 2025-01-22 - -### Added -- Added Agent Skill to all artifacts - -### Changed -- **MCPB Submission Compliance**: Bundle now includes LICENSE and CHANGELOG.md per Anthropic requirements -- **Documentation Updates**: All READMEs updated with LLM-tested example prompts and accurate tool counts (22 tools, 194 operations) - -## [1.5.8] - 2025-01-20 - -### Added -- Now available as a Claude Desktop MCPB Extension - -## [1.5.6] - 2025-01-20 - -### Added -- **PivotTable & Table Slicers** (#363): New `slicer` tool for interactive filtering - - **PivotTable Slicers**: Create, list, filter, and delete slicers for PivotTable fields - - **Table Slicers**: Create, list, filter, and delete slicers for Excel Table columns - - 8 new operations for interactive data filtering - -## [1.5.5] - 2025-01-19 - -### Added -- **DMV Query Execution** (#353): Query Data Model metadata using Dynamic Management Views - - New `execute-dmv` action on `datamodel` tool - - Query TMSCHEMA_MEASURES, TMSCHEMA_RELATIONSHIPS, DISCOVER_CALC_DEPENDENCY, etc. - -## [1.5.4] - 2025-01-19 - -### Added -- **DAX EVALUATE Query Execution** (#356): Execute DAX queries against the Data Model - - New `evaluate` action on `datamodel` tool for ad-hoc DAX queries -- **DAX-Backed Excel Tables** (#356): Create worksheet tables populated by DAX queries - - New `create-from-dax`, `update-dax`, `get-dax` actions - -## [1.5.0] - 2025-01-10 - -### Changed -- **Tool Reorganization** (#341): Split 12 monolithic tools into 21 focused tools - - 186 operations total, better organized for AI assistants - - Ranges: 4 tools (range, range_edit, range_format, range_link) - - PivotTables: 3 tools (pivottable, pivottable_field, pivottable_calc) - - Tables: 2 tools (table, table_column) - - Data Model: 2 tools (datamodel, datamodel_rel) - - Charts: 2 tools (chart, chart_config) - - Worksheets: 2 tools (worksheet, worksheet_style) - -### Added -- **LLM Integration Testing** (#341): Real AI agent testing using [pytest-aitest](https://github.com/sbroenne/pytest-aitest) - -### Changed -- **.NET 10 Upgrade**: Requires .NET 10.0 instead of .NET 8.0 - -## [1.4.42] - 2025-12-15 - -### Added -- **Power Query Rename** (#326, #327): New `rename` action for Power Query queries -- **Data Model Table Rename** (#326, #327): New `rename-table` action for Data Model tables - -## [1.4.41] - 2025-12-14 - -### Fixed -- **Power Query Data Model Fix** (#324): Fixed "0x800A03EC" error when updating Power Query in workbooks with Data Model present - -## [1.4.40] - 2025-12-14 - -### Changed -- **MCP SDK Upgrade** (#301): Upgraded ModelContextProtocol SDK from 0.4.1-preview.1 to 0.5.0-preview.1 - - Proper `isError` signaling for tool execution failures - - Deterministic exit codes (0 = success, 1 = fatal error) - -## [1.4.37] - 2025-12-06 - -### Changed -- **PivotTable Performance** (#286): Optimized `RefreshTable()` calls - -### Added -- **Data Model Members** (#288): Added support for Data Model table members - -## [1.4.36] - 2025-12-06 -### Changed -- **Documentation Updates** (#290): Updated tool/operation counts - -### Fixed -- **SEO Fix** (#292): Fixed robots.txt sitemap URL - -## [1.4.35] - 2025-12-05 - -### Added -- **Data Model Relationships** (#278): Full support for creating, updating, and deleting relationships -- **Custom Domain** (#276): excelmcpserver.dev - -## [1.4.34] - 2025-12-05 - -### Fixed -- **DAX Formula Locale Handling** (#281): DAX formulas now work on European locales - -## [1.4.33] - 2025-12-04 - -### Changed -- **Atomic Cross-File Worksheet Operations** (#273): New `copy-to-file` and `move-to-file` actions - -## [1.4.32] - 2025-12-04 - -### Fixed -- **OLAP PivotChart Creation** (#267): `CreateFromPivotTable` now works with OLAP/Data Model PivotTables -- **Power Query LoadToBoth Detection** (#271): Fixed incorrect detection - -## [1.4.31] - 2025-12-04 - -### Fixed -- **Locale-Independent Number Formatting** (#263): Number and date formats now work on non-US locales - -## [1.4.30] - 2025-12-03 - -### Fixed -- **OLAP PivotTable AddValueField** (#261): Fixed errors when adding value fields to Data Model PivotTables - -### Added -- **Show Excel Mode**: Open with `showExcel: true` to watch AI changes live - -## [1.4.28] - 2025-12-01 - -### Fixed -- **VS Code Extension Display Name** (#257): Corrected MCP server display name - -## [1.4.25] - 2025-12-01 - -### Changed -- **89% Smaller Extension Size** (#250): Switched to framework-dependent deployment - -## [1.4.24] - 2025-12-01 - -### Fixed -- **Session Stability** (#245): Fixed Excel MCP Server stopping due to network errors - -### Added -- **PivotTable Grand Totals Control**: Show/hide row and column grand totals -- **PivotTable Grouping**: Group dates by days/months/quarters/years -- **PivotTable Calculated Fields**: Create calculated fields with formulas -- **PivotTable Layout & Subtotals**: Configure layout form and subtotals visibility -- Total operations: 172 - -## [1.4.0] - 2025-11-24 - -### Added -- **Excel Table Get Data** (#234): New `get-data` action returns table rows - -### Fixed -- **Power Query Error Query Fix** (#236): Fixed spurious "Error Query" entries - -## [1.3.0] - 2025-11-22 - -### Added -- **Chart Operations** (#229): 15 new chart actions -- **Connection Delete** (#226): New `delete` action -- **OLAP PivotTable Measures** (#217): Auto-create DAX measures - -### Changed -- **PivotTable Enhancements** (#219, #220): Date/numeric grouping, calculated fields - -## [1.2.0] - 2025-11-17 - -### Added -- **Worksheet Reordering** (#186): New `move` action - -### Fixed -- **MCP Server Crash Fix** (#192): Fixed crashes with disconnected COM proxies -- **Connection Create Fix** (#190): Fixed COM dispatch error - -## [1.1.0] - 2025-11-10 - -### Fixed -- **File Lock Fix** (#173): Fixed "file already open" errors -- **LoadTo Silent Failure Fix** (#170): LoadTo now properly fails on duplicates -- **Validation InputTitle/Message** (#167): Fixed empty values -- **Power Query Update Fix** (#140): Fixed M code merging instead of replacing -- **SetFormulas/SetValues Fix** (#199): Fixed "out of memory" error -- **Data Model Loading Fix** (#64): Fixed `set-load-to-data-model` failures -- **Power Query Persistence** (#42): Fixed load-to-data-model not persisting - -### Added -- **PivotTable Discovery** (#155): Improved LLM discoverability -- **CLI Batch Support** (#152): Batch mode for bulk operations -- **Timeout Support** (#131): Configurable timeouts for all tools -- **QueryTable Support** (#129): New `excel_querytable` tool -- **Connection Create** (#127): New `create` action -- **PivotTable from Data Model** (#109): Create PivotTables from Power Pivot - -### Changed -- **Numeric Column Names** (#136): Column names can now be numeric - -## [1.0.0] - 2025-10-29 - -### Added -- Initial release of ExcelMcp -- MCP Server with 11 tools and 100+ operations -- CLI for command-line scripting -- VS Code Extension for one-click installation -- Power Query management -- Data Model / Power Pivot support -- Excel Tables and PivotTables -- Range operations with formulas -- Chart creation -- Named ranges and parameters -- VBA macro execution -- Worksheet lifecycle management -- Batch operations for performance +- **32 PowerPoint MCP tools with 170 operations** for comprehensive PowerPoint automation via COM interop +- **Slide management** (7 ops) — list, read, create, duplicate, move, delete, apply-layout +- **Shape operations** (17 ops) — add, move, resize, fill, line, shadow, rotation, z-order, grouping, copy between slides, connectors, merge shapes (union/combine/fragment/intersect/subtract) +- **Text editing** (6 ops) — get/set text, find, replace, format (font, size, bold, italic, color, alignment) +- **Charts** (5 ops) — create, get info, set title, set type, delete +- **Slide Tables** (9 ops) — create, read, write cells, add/delete rows and columns, merge cells +- **Animations** (4 ops) — list, add, remove, clear effects +- **Transitions** (3 ops) — get, set, remove slide transitions +- **Design/Themes** (4 ops) — list designs, apply themes, get theme colors, list color schemes +- **Images** (1 op) — insert with position and size control +- **Speaker Notes** (3 ops) — get, set, clear +- **Sections** (4 ops) — list, add, rename, delete presentation sections +- **Hyperlinks** (4 ops) — add, read, remove, list +- **Slideshow** (4 ops) — start, stop, navigate, get status +- **Slide Masters** (1 op) — list masters and layouts +- **Export** (4 ops) — PDF, slide images (PNG), video (MP4), print +- **VBA Macros** (5 ops) — list, view, import, delete, run +- **Media** (3 ops) — insert audio/video, get media info +- **Window Management** (4 ops) — get info, minimize, restore, maximize +- **File Validation** (1 op) — test file accessibility +- **Document Properties** (2 ops) — get/set title, author, subject, etc. +- **Comments** (4 ops) — list, add, delete, clear slide comments +- **Placeholders** (2 ops) — list placeholders, set placeholder text +- **Slide Background** (3 ops) — get info, set solid color, reset to master +- **Headers & Footers** (2 ops) — get/set footer text, slide numbers, date +- **SmartArt** (2 ops) — get diagram info, add nodes +- **Shape Alignment** (2 ops) — align and distribute shapes on slides +- **Custom Shows** (3 ops) — list, create, delete custom slide shows +- **Page Setup** (2 ops) — get/set slide size and orientation +- **Slide Import** (1 op) — import slides from another .pptx file +- **Tags** (3 ops) — custom metadata on slides and shapes +- **MCP Server** — Model Context Protocol server for AI assistants (GitHub Copilot, Claude, ChatGPT) +- **CLI** (`pptcli`) — Command-line interface for scripting and coding agents +- **COM interop** — Uses PowerPoint's native COM API for 100% safe automation +- **Session management** — Shared sessions between MCP Server and CLI +- **Parameter validation** — All required string parameters validated before COM execution +- **COM resource safety** — All COM objects released in finally blocks to prevent leaks diff --git a/Directory.Build.props b/Directory.Build.props index 0aad46f3..326e0cc9 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,24 +2,24 @@ - 1.7.1 - 1.7.1.0 - 1.7.1.0 - Stefan Broenner + 0.1.0 + 0.1.0.0 + 0.1.0.0 + Torsten Mahr Open Source Developer - Sbroenne.ExcelMcp - Copyright © 2025 Stefan Broenner + PptMcp + Copyright © 2026 Torsten Mahr - https://github.com/sbroenne/mcp-server-excel - https://github.com/sbroenne/mcp-server-excel + https://github.com/torstenmahr/mcp-server-ppt + https://github.com/torstenmahr/mcp-server-ppt git LICENSE enable enable - 14 + 13 true @@ -61,11 +61,11 @@ - - + diff --git a/Directory.Build.props.user.template b/Directory.Build.props.user.template deleted file mode 100644 index c147d1de..00000000 --- a/Directory.Build.props.user.template +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - YOUR_CONNECTION_STRING_HERE - - diff --git a/Directory.Build.targets b/Directory.Build.targets index bdd1a4a3..a1d8b31b 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -1,7 +1,7 @@ diff --git a/Directory.Packages.props b/Directory.Packages.props index ff2a0af8..801216fe 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,31 +5,25 @@ true - - + + - - - + + - - - - - - - - - + + + + - + @@ -37,13 +31,13 @@ - + - + - - + + \ No newline at end of file diff --git a/FEATURES.md b/FEATURES.md index fd9d6f6c..fdf3521a 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1,431 +1,269 @@ -# ExcelMcp - Complete Feature Reference +# PptMcp - Complete Feature Reference -**25 specialized tools with 225 operations for comprehensive Excel automation** +**32 specialized tools with 170 operations for comprehensive PowerPoint automation** --- -## 📁 File Operations (6 operations) +## 📄 Slide Operations (7 operations) -- **List Sessions:** View all active Excel sessions -- **Open:** Open workbook and create session (returns session ID for all subsequent operations). IRM/AIP-protected files are automatically detected and opened read-only with Excel visible for credential authentication — no extra parameters needed. -- **Close:** Close session with optional save -- **Close Workbook:** Close workbook without closing Excel -- **Create Empty:** Create new .xlsx or .xlsm workbook -- **Test:** Verify workbook can be opened and is accessible. Returns `isIrmProtected` flag for IRM/AIP-protected files. +- **List:** List all slides in the presentation with summary info +- **Read:** Get detailed information about a specific slide +- **Create:** Create a new blank slide +- **Duplicate:** Duplicate an existing slide +- **Move:** Move a slide to a different position +- **Delete:** Delete a slide from the presentation +- **Apply Layout:** Apply a slide layout to an existing slide --- -## 🧮 Calculation Mode (3 operations) - -- **Get Mode:** Query current calculation mode and calculation state -- **Set Mode:** Switch between automatic, manual, and semi-automatic modes -- **Calculate:** Explicitly recalculate workbook, sheet, or range +## 🔷 Shape Operations (17 operations) + +- **List:** List all shapes on a slide +- **Read:** Get detailed information about a specific shape +- **Add Textbox:** Add a new textbox shape to a slide +- **Add Shape:** Add an auto-shape (rectangle, oval, arrow, etc.) to a slide +- **Move/Resize:** Move and/or resize a shape (position, width, height) +- **Delete:** Delete a shape from a slide +- **Z-Order:** Change shape stacking order (bring to front, send to back, etc.) +- **Set Fill:** Set shape fill color or style +- **Set Line:** Set shape outline/border properties +- **Set Rotation:** Set shape rotation angle +- **Group:** Group multiple shapes together +- **Ungroup:** Ungroup a grouped shape +- **Set Alt Text:** Set accessibility alt text on a shape +- **Copy to Slide:** Copy a shape to another slide +- **Set Shadow:** Apply or modify shadow effects on a shape +- **Add Connector:** Add a connector line between two shapes +- **Merge:** Merge multiple shapes (union, intersect, subtract, etc.) --- -## 🔄 Power Query & M Code (12 operations) - -**Atomic Operations** - Single-call workflows: -- **List:** List all Power Query queries in workbook -- **View:** View the M code of a Power Query -- **Create:** Import + load in one operation (atomic workflow) with automatic formatting -- **Update:** Update M code with automatic formatting and auto-refresh -- **Rename:** Rename a Power Query (trim + case-insensitive uniqueness check) -- **Refresh:** Refresh a Power Query with timeout detection -- **Refresh All:** Batch refresh all queries in workbook -- **Load To:** Configure load destination and refresh (atomic) -- **Get Load Config:** Get current load configuration -- **Unload:** Remove data from all destinations (keeps query definition) -- **Delete:** Remove Power Query from workbook -- **Evaluate:** Execute M code directly and return results (without creating a permanent query) +## ✏️ Text Operations (5 operations) -**Automatic M-Code Formatting:** M code is automatically formatted on write operations (Create, Update) using the powerqueryformatter.com API (by mogularGmbH, MIT License). Read operations return M code as stored in Excel. Formatting adds ~100-500ms network latency but dramatically improves readability with proper indentation, spacing, and line breaks. Graceful fallback returns original M code if formatting fails. +- **Get:** Get text content from a shape +- **Set:** Set text content in a shape +- **Find:** Find text across slides +- **Replace:** Find and replace text across slides +- **Format:** Apply text formatting (font, size, color, bold, italic, etc.) --- -## 📊 Data Model & DAX (Power Pivot) (19 operations) +## 📊 Chart Operations (5 operations) -- **List Tables:** Discover all tables in the Data Model -- **Read Table:** Get specific table information -- **Rename Table:** Rename a Data Model table (best-effort via Power Query; returns clear error if not supported) -- **List Columns:** List columns for a table -- **List Measures:** List all DAX measures with formula previews -- **Read Info:** Get comprehensive model information -- **Create Measure:** Create new DAX measure with automatic formatting (format types: Currency, Percentage, Decimal, General) -- **Update Measure:** Modify existing measure with automatic formatting -- **Delete Measure:** Remove measure from model -- **Delete Table:** Remove table from Data Model -- **List Relationships:** View all table relationships -- **Read Relationship:** Get specific relationship info -- **Create Relationship:** Create relationship between tables -- **Update Relationship:** Modify relationship (toggle active/inactive) -- **Delete Relationship:** Remove relationship -- **Refresh:** Refresh entire Data Model -- **List Workbook Connections:** List Power Query sources available for integration -- **Evaluate:** Execute DAX EVALUATE queries and return tabular results (for ad-hoc analysis) -- **Execute DMV:** Execute SQL-like DMV (Dynamic Management View) queries for metadata discovery +- **Create:** Create a new chart on a slide +- **Get Info:** Get chart details (type, data range, series) +- **Set Title:** Set or update the chart title +- **Set Type:** Change the chart type +- **Delete:** Delete a chart from a slide + +--- -**Automatic DAX Formatting:** DAX formulas are automatically formatted on write operations (CreateMeasure, UpdateMeasure) using the official Dax.Formatter library (SQLBI). Read operations return raw DAX as stored in Excel. Formatting adds ~100-500ms network latency but dramatically improves readability. Graceful fallback returns original DAX if formatting fails. +## 📋 Table Operations (8 operations) -**Note:** DAX calculated columns not supported - use Excel UI for calculated columns +- **Create:** Create a new table on a slide +- **Read:** Read table contents and structure +- **Write Cell:** Write a value to a specific table cell +- **Add Row:** Add a row to an existing table +- **Add Column:** Add a column to an existing table +- **Delete Row:** Delete a row from a table +- **Delete Column:** Delete a column from a table +- **Merge Cells:** Merge a range of table cells --- -## 🎨 Excel Tables (ListObjects) (27 operations) +## 🎬 Animation Operations (4 operations) -**Lifecycle:** -- List, read, create, rename, resize, delete tables +- **List:** List all animations on a slide +- **Add:** Add an animation effect to a shape +- **Remove:** Remove a specific animation from a shape +- **Clear:** Remove all animations from a slide -**Styling & Formatting:** -- Apply table styles -- Toggle totals row -- Set column totals +--- + +## 🔀 Transition Operations (3 operations) -**Data Operations:** -- Append rows -- Get table data (with optional visible-only filtering) -- Add to Data Model +- **Get:** Get the current transition settings for a slide +- **Set:** Set or change the slide transition effect +- **Remove:** Remove the transition from a slide -**DAX-Backed Tables:** -- Create from DAX (create Excel Table populated by DAX EVALUATE query) -- Update DAX (change the DAX query of an existing DAX-backed table) -- Get DAX (retrieve DAX query info from a table) +--- -**Filter Operations:** -- Apply filter (criteria) -- Apply filter (values) -- Clear filters -- Get filter state +## 🎨 Design Operations (4 operations) -**Column Management:** -- Add, remove, rename columns +- **List:** List available themes +- **Apply Theme:** Apply a theme to the presentation +- **Get Colors:** Get the current theme color palette +- **List Color Schemes:** List available color schemes -**Structured References:** -- Get structured reference (formula syntax for table columns/ranges) +--- -**Sorting:** -- Single-column sort -- Multi-column sort (up to 3 levels) +## 🖼️ Image Operations (1 operation) -**Number Formatting:** -- Get column number formats -- Set column number formats +- **Insert:** Insert an image onto a slide from a file path --- -## 📈 PivotTables (30 operations) +## 📝 Notes Operations (3 operations) -**Creation:** -- Create from range -- Create from Excel Table -- Create from Data Model +- **Get:** Get speaker notes for a slide +- **Set:** Set or update speaker notes for a slide +- **Clear:** Remove speaker notes from a slide -**Field Management:** -- List all fields (row, column, value, filter areas) -- Add row field, column field, value field, filter field -- Remove field - -**Field Configuration:** -- Set field aggregation function (Sum, Average, Count, Min, Max, etc.) -- Set custom field name -- Set field number format -- Set field filter criteria -- Sort field (ascending/descending) - -**Calculated Fields (Regular PivotTables):** -- List calculated fields -- Create calculated field -- Delete calculated field - -**Calculated Members (OLAP/Data Model PivotTables):** -- List calculated members -- Create calculated member -- Delete calculated member - -**Layout & Formatting:** -- Set layout (table or outline) -- Set subtotals display -- Set grand totals display - -**Data Operations:** -- Get PivotTable data as 2D array -- Refresh PivotTable - -**Lifecycle:** -- List PivotTables -- Read PivotTable info -- Delete PivotTable - ---- - -## 📉 Charts (28 operations) - -**Creation:** -- Create from range -- Create from PivotTable - -**Series Management:** -- Add series -- Remove series -- Update series data - -**Configuration:** -- Set data source range -- Set chart type -- Show/hide legend -- Set style - -**Formatting:** -- Set chart title -- Set axis title -- Set axis number format -- Get axis number format - -**Data Labels:** -- Configure data labels (show values, percentages, category names, etc.) -- Set label position (Center, InsideEnd, OutsideEnd, etc.) -- Apply to all series or specific series - -**Axis Scale:** -- Get axis scale settings -- Set minimum/maximum scale -- Set major/minor units - -**Gridlines:** -- Get gridlines configuration -- Set major/minor gridlines visibility - -**Series Formatting:** -- Set marker style (Circle, Square, Diamond, Triangle, etc.) -- Set marker size -- Set marker colors - -**Trendlines:** -- Add trendline (Linear, Exponential, Logarithmic, Polynomial, Power, MovingAverage) -- List trendlines on series -- Delete trendline -- Configure trendline (forecast forward/backward, display equation, display R²) - -**Placement & Positioning:** -- Set chart placement (move/size with cells options) -- Fit to range (position and size to match a range) - -**Lifecycle:** -- List charts -- Read chart info -- Move chart (to different worksheet or new sheet) -- Delete chart - ---- - -## 📋 Ranges (42 operations) - -**Data Operations:** -- Get values -- Set values -- Get formulas -- Set formulas -- Clear all -- Clear contents -- Clear formats -- Copy -- Copy values -- Copy formulas -- Insert cells -- Delete cells -- Insert rows -- Delete rows -- Insert columns -- Delete columns -- Find -- Replace -- Sort - -**Discovery & Utilities:** -- Get used range -- Get current region -- Get range info (address, dimensions) - -**Hyperlinks:** -- Add hyperlink -- Remove hyperlink -- List hyperlinks -- Get specific hyperlink - -**Number Formatting:** -- Get number formats (as 2D array) -- Set number format (uniform) -- Set number formats (individual) - -**Visual Formatting:** -- Get style -- Set style (built-in Excel styles) -- Format range (font, color, borders, alignment, orientation) - -**Data Validation:** -- Add validation rules (dropdowns, number/date/text rules) -- Get validation info -- Remove validation - -**Merge Operations:** -- Merge cells -- Unmerge cells -- Get merge info - -**Cell Protection:** -- Set cell lock status -- Get cell lock status - -**Auto-Sizing:** -- Auto-fit columns -- Auto-fit rows - ---- - -## 📄 Worksheets (16 operations) - -**Lifecycle:** -- List worksheets -- Create worksheet -- Rename worksheet -- Copy worksheet -- Move worksheet -- Delete worksheet - -**Cross-Workbook Operations:** -- Copy worksheet to file (atomic) -- Move worksheet to file (atomic) - -**Tab Colors:** -- Set tab color (RGB) -- Get tab color -- Clear tab color - -**Visibility:** -- Show worksheet -- Hide worksheet -- Very hide worksheet (hidden from UI) -- Get visibility status -- Set visibility status +--- + +## 📂 Section Operations (4 operations) + +- **List:** List all sections in the presentation +- **Add:** Add a new section +- **Rename:** Rename an existing section +- **Delete:** Delete a section --- -## 🔌 Data Connections (9 operations) +## 🔗 Hyperlink Operations (4 operations) -- **List:** View all data connections -- **View:** Get connection details -- **Create:** Create OLEDB/ODBC connections (requires provider installed) -- **Test:** Verify connection validity -- **Refresh:** Refresh connection data -- **Delete:** Remove connection -- **Load To:** Load connection data to worksheet (when supported) -- **Get Properties:** Get connection string and metadata -- **Set Properties:** Update connection string, command text, and settings - -**Supported Types:** -- OLEDB (requires Microsoft.ACE.OLEDB.16.0 or similar) -- ODBC (requires ODBC driver installed) -- Power Query connections (atomic redirect to powerquery) +- **Add:** Add a hyperlink to a shape or text +- **Read:** Get hyperlink details from a shape +- **Remove:** Remove a hyperlink from a shape +- **List:** List all hyperlinks in the presentation -**Automatic Fallback:** -- TEXT/WEB connections automatically redirect to powerquery for reliable imports +--- + +## ▶️ Slideshow Operations (4 operations) + +- **Start:** Start the slideshow presentation +- **Stop:** End the slideshow +- **Goto Slide:** Navigate to a specific slide during the slideshow +- **Get Status:** Get the current slideshow status (running, slide number, etc.) --- -## 🏷️ Named Ranges (Parameters) (6 operations) +## 🎭 Slide Master Operations (1 operation) + +- **List:** List all slide masters and their layouts -- **List:** List all named ranges with references -- **Read:** Get value of a named range -- **Write:** Set value of a named range (ideal for parameter automation) -- **Create:** Create new named range -- **Update:** Modify existing named range -- **Delete:** Remove named range +--- + +## 📤 Export Operations (4 operations) -**Use Cases:** -- Workbook parameter management without touching worksheets -- Ideal for automation: update parameter → Power Query refreshes automatically +- **To PDF:** Export the presentation to PDF +- **Slide to Image:** Export a specific slide as an image (PNG/JPEG) +- **To Video:** Export the presentation to video format +- **Print:** Print the presentation --- -## 📝 VBA Macros (6 operations) +## 📝 VBA Macros (5 operations) - **List:** List all VBA modules and procedures -- **View:** Display module code without exporting +- **View:** Display module code - **Import:** Add VBA module from file -- **Update:** Modify existing VBA module - **Delete:** Remove VBA module -- **Run:** Execute macro with optional parameters +- **Run:** Execute a macro with optional parameters + +--- -**Features:** -- Version control through file exports -- Parameter passing to macros -- Full module lifecycle management +## 🎵 Media Operations (3 operations) + +- **Insert Audio:** Insert an audio file onto a slide +- **Insert Video:** Insert a video file onto a slide +- **Get Info:** Get media object details (type, duration, file path) --- -## �️ Slicers (8 operations) +## 🪟 Window Operations (4 operations) + +- **Get Info:** Get current window state (position, size, view type) +- **Minimize:** Minimize the PowerPoint window +- **Restore:** Restore the PowerPoint window to normal size +- **Maximize:** Maximize the PowerPoint window -**PivotTable Slicers:** -- **Create Slicer:** Add slicer for PivotTable field with optional position -- **List Slicers:** List all PivotTable slicers in workbook -- **Set Selection:** Filter PivotTable by slicer selection (single or multi-select) -- **Delete Slicer:** Remove PivotTable slicer +--- -**Table Slicers:** -- **Create Table Slicer:** Add slicer for Excel Table column -- **List Table Slicers:** List all Table slicers in workbook -- **Set Table Selection:** Filter Table by slicer selection -- **Delete Table Slicer:** Remove Table slicer +## 📁 File Operations (1 operation) -**Use Cases:** -- Interactive data filtering without modifying PivotTable/Table structure -- Dashboard creation with visual filter controls -- Multi-slicer filtering for complex data analysis +- **Test:** Verify a presentation file can be opened and is accessible --- -## �🎨 Conditional Formatting (2 operations) +## 🏷️ Document Property Operations (2 operations) -- **Add Rule:** Create conditional formatting rules - - Cell value comparisons (>, <, =, etc.) - - Expression-based formulas (custom DAX/Excel formulas) - - Color scales, data bars, icons -- **Clear Rules:** Remove formatting from ranges +- **Get All:** Get all document properties (title, author, subject, etc.) +- **Set All:** Set document properties --- -## 📸 Screenshot (2 operations) +## 💬 Comment Operations (4 operations) -- **Capture Range:** Capture a specific range as a PNG image -- **Capture Sheet:** Capture the entire used area of a worksheet as a PNG image - - Uses Excel's built-in rendering (CopyPicture) — captures formatting, charts, conditional formatting - - MCP: Returns image directly as ImageContent (base64 PNG) - - CLI: Returns JSON with base64-encoded image data +- **List:** List all comments in the presentation +- **Add:** Add a new comment to a slide +- **Delete:** Delete a specific comment +- **Clear:** Remove all comments from the presentation --- -## 🪧 Window Management (9 operations) +## 📌 Placeholder Operations (2 operations) -- **Show:** Makes Excel visible and brings it to the foreground -- **Hide:** Hides the Excel window -- **Bring to Front:** Brings Excel to the foreground without changing visibility -- **Get Info:** Gets current window state (visibility, position, size, foreground status) -- **Set State:** Sets window state to normal, minimized, or maximized -- **Set Position:** Sets window position and size in points (left, top, width, height) -- **Arrange:** Arranges Excel window using preset layouts -- **Set Status Bar:** Displays custom text in Excel's status bar for real-time feedback -- **Clear Status Bar:** Restores the default status bar text +- **List:** List all placeholders on a slide (title, subtitle, content, etc.) +- **Set Text:** Set text content in a placeholder -**Arrange Presets:** -- `left-half` / `right-half` — Side-by-side with other applications -- `top-half` / `bottom-half` — Stacked view -- `center` — Centered window (60% of screen) -- `full-screen` — Maximized +--- -**Use Cases:** -- Interactive "agent mode" where users watch Excel respond to AI commands in real-time -- Side-by-side: Excel on one half, AI assistant on the other -- Visibility changes are reflected in session metadata (session list shows updated state) +## 🖌️ Background Operations (3 operations) + +- **Get Info:** Get the current slide background settings +- **Set Color:** Set a solid color background on a slide +- **Reset:** Reset the slide background to the default theme background + +--- + +## 📃 Header & Footer Operations (2 operations) + +- **Get Info:** Get header and footer settings (date, slide number, footer text) +- **Update:** Update header and footer settings + +--- + +## 🧩 SmartArt Operations (2 operations) + +- **Get Info:** Get SmartArt graphic details (type, nodes, layout) +- **Add Node:** Add a new node to an existing SmartArt graphic + +--- + +## ↔️ Shape Alignment Operations (2 operations) + +- **Align:** Align shapes (left, center, right, top, middle, bottom) +- **Distribute:** Distribute shapes evenly (horizontally or vertically) + +--- + +## 🎞️ Custom Show Operations (3 operations) + +- **List:** List all custom slide shows +- **Create:** Create a new custom slide show from selected slides +- **Delete:** Delete a custom slide show + +--- + +## 📐 Page Setup Operations (2 operations) + +- **Get Info:** Get slide size and orientation settings +- **Set Size:** Set slide dimensions and orientation + +--- + +## 📥 Slide Import Operations (1 operation) + +- **Import:** Import slides from another PowerPoint presentation + +--- + +## 🏷️ Tag Operations (3 operations) + +- **List:** List all tags on a slide or shape +- **Set:** Add or update a tag value +- **Delete:** Remove a tag --- @@ -433,52 +271,80 @@ | Category | Operations | |----------|-----------| -| File Operations | 6 | -| Power Query | 12 | -| Data Model/DAX | 19 | -| Excel Tables | 27 | -| PivotTables | 30 | -| Charts | 28 | -| Ranges | 42 | -| Worksheets | 16 | -| Connections | 9 | -| Named Ranges | 6 | -| VBA Macros | 6 | -| Slicers | 8 | -| Conditional Formatting | 2 | -| Screenshot | 2 | -| Calculation Mode | 3 | -| Window Management | 9 | -| **Total** | **225** | +| Slide | 7 | +| Shape | 17 | +| Text | 5 | +| Chart | 5 | +| Table | 8 | +| Animation | 4 | +| Transition | 3 | +| Design | 4 | +| Image | 1 | +| Notes | 3 | +| Section | 4 | +| Hyperlink | 4 | +| Slideshow | 4 | +| Master | 1 | +| Export | 4 | +| VBA | 5 | +| Media | 3 | +| Window | 4 | +| File | 1 | +| Document Property | 2 | +| Comment | 4 | +| Placeholder | 2 | +| Background | 3 | +| Header & Footer | 2 | +| SmartArt | 2 | +| Shape Alignment | 2 | +| Custom Show | 3 | +| Page Setup | 2 | +| Slide Import | 1 | +| Tag | 3 | +| **Total** | **104** | --- ## 🚀 Key Capabilities -**Data Transformation:** -- Comprehensive Power Query M code management -- Atomic import + load workflows -- Calculated fields and members for analysis - -**Data Model:** -- Full DAX measure lifecycle -- Relationship management -- Multi-table integration - -**Analysis & Visualization:** -- PivotTable creation and configuration -- Chart automation -- Custom calculations - -**Automation:** +**Slide Management:** +- Full slide lifecycle (create, duplicate, move, delete) +- Layout and master slide support +- Section organization +- Import slides from other presentations + +**Content Creation:** +- Rich shape creation and manipulation (textboxes, auto-shapes, connectors) +- Table creation with cell-level control +- Chart creation and configuration +- Image, audio, and video insertion +- SmartArt management + +**Design & Formatting:** +- Theme and color scheme management +- Shape fill, line, shadow, and rotation +- Text formatting (font, size, color, bold, italic) +- Slide background customization +- Shape alignment and distribution + +**Presentation Delivery:** +- Slideshow control (start, stop, navigate) +- Animation and transition effects +- Custom slide shows +- Speaker notes management + +**Automation & Export:** - VBA macro execution and management -- Named range parameter automation -- Conditional formatting rules +- Export to PDF, image, and video +- Print support +- Find and replace across slides -**Data Loading:** -- Multiple connection type support -- OLEDB/ODBC management -- Power Query atomic workflows +**Metadata & Organization:** +- Document properties management +- Comments and review workflow +- Tags for custom metadata +- Header and footer configuration +- Placeholder content management --- @@ -486,21 +352,20 @@ | Task | Tool | |------|------| -| Import data | `powerquery` or `connection` | -| Create analysis | `pivottable` (data model-based for OLAP) | -| Visualize data | `chart` | -| Update parameters | `namedrange` (write operation) | -| Manage formulas | `range` (set-formulas) | -| Format data | `range` (format-range, validate-range) | -| Script automation | `vba` (run macro) | - ---- - -## 📚 Documentation - -- **[Installation Guide](https://github.com/sbroenne/mcp-server-excel/blob/main/docs/INSTALLATION.md)** - Setup for all AI assistants -- **[MCP Server Guide](https://github.com/sbroenne/mcp-server-excel/blob/main/src/ExcelMcp.McpServer/README.md)** - Tool documentation and examples -- **[CLI Guide](https://github.com/sbroenne/mcp-server-excel/blob/main/src/ExcelMcp.CLI/README.md)** - Command-line reference -- **[Agent Skills](https://github.com/sbroenne/mcp-server-excel/blob/main/skills/excel-mcp/SKILL.md)** - Cross-platform AI assistant guidance (agentskills.io) -- **[Contributing](https://github.com/sbroenne/mcp-server-excel/blob/main/docs/CONTRIBUTING.md)** - Development guidelines -- **[Releases](https://github.com/sbroenne/mcp-server-excel/releases)** - Latest updates and features +| Add/manage slides | `slide` | +| Add/modify shapes | `shape` | +| Edit text content | `text` or `placeholder` | +| Create charts | `chart` | +| Create tables | `table` | +| Add animations | `animation` | +| Set transitions | `transition` | +| Change theme/colors | `design` | +| Insert images | `image` | +| Insert media | `media` | +| Manage speaker notes | `notes` | +| Export presentation | `export` | +| Run slideshow | `slideshow` | +| Script automation | `vba` | +| Align/distribute shapes | `shapealign` | +| Manage comments | `comment` | +| Import slides | `slideimport` | diff --git a/LICENSE b/LICENSE index f0357e3f..565e97b4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License Copyright (c) 2025 Stefan Broenner +Copyright (c) 2026 Torsten Mahr Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/PRIVACY.md b/PRIVACY.md deleted file mode 100644 index 711076a3..00000000 --- a/PRIVACY.md +++ /dev/null @@ -1,100 +0,0 @@ -# Privacy Policy - -**Last Updated:** January 13, 2026 - -## Overview - -MCP Server for Excel ("ExcelMcp") is an open-source tool that enables AI assistants to interact with Microsoft Excel. This privacy policy explains how the software handles your data. - -## Data Collection Summary - -ExcelMcp collects **limited, anonymous telemetry** to improve the software. Here's what we do and don't collect: - -### What We DO Collect (Anonymous Telemetry) - -- **Tool usage statistics** - Which tools and actions are used (e.g., "range/get-values") -- **Performance metrics** - How long operations take (duration in milliseconds) -- **Success/failure rates** - Whether operations completed successfully -- **Session information** - A random session ID generated each time the server starts -- **Anonymous user ID** - A hashed identifier based on machine identity (not personally identifiable) -- **Application version** - Which version of ExcelMcp is running -- **Unhandled exceptions** - Error types (not error messages or stack traces with sensitive data) - -### What We DO NOT Collect - -- ❌ **File contents** - We never collect data from your Excel files -- ❌ **File names or paths** - File paths are hashed locally; actual paths are never transmitted -- ❌ **Personal information** - No names, emails, or account information -- ❌ **Spreadsheet data** - Cell values, formulas, and data remain completely private -- ❌ **User accounts** - No registration or sign-in required - -### Purpose of Telemetry - -We use anonymous telemetry to: -- Understand which features are most used -- Identify and fix performance issues -- Prioritize development of new features -- Detect and fix bugs - -### Telemetry Infrastructure - -Telemetry is sent to **Azure Application Insights**, a Microsoft service. Data is: -- Transmitted over HTTPS -- Stored in accordance with Microsoft's data handling policies -- Retained for analytics purposes only - -## How It Works - -ExcelMcp operates on your local machine: - -1. **Local Processing** - All Excel operations are performed locally via Microsoft's COM API -2. **Your Files Stay Local** - Excel files are read from and written to your local filesystem only -3. **Minimal Network Usage** - The only network traffic is anonymous telemetry to Azure Application Insights - -## Data Flow - -When you use ExcelMcp with an AI assistant (like Claude): - -1. You send a request to the AI assistant -2. The AI assistant calls ExcelMcp tools on your local machine -3. ExcelMcp performs the requested Excel operations locally -4. Anonymous usage telemetry is sent to Azure Application Insights -5. Results are returned to the AI assistant - -**Note:** The AI assistant you use (e.g., Claude) has its own privacy policy governing how it handles your conversations and data. ExcelMcp only handles the local Excel operations and sends anonymous usage metrics. - -## Third-Party Services - -- **Azure Application Insights** - Anonymous telemetry is sent to this Microsoft service. See [Microsoft's Privacy Statement](https://privacy.microsoft.com/privacystatement). -- **Microsoft Excel** - ExcelMcp requires Microsoft Excel installed on your machine. Excel is subject to Microsoft's privacy policy. -- **AI Assistants** - When used with AI assistants like Claude, those services have their own privacy policies. - -## Open Source - -ExcelMcp is open source software. You can review the complete source code at: -https://github.com/sbroenne/mcp-server-excel - -## Security - -- ExcelMcp runs with the same permissions as your user account -- It can only access files and Excel instances that your user account can access -- No elevated privileges are required or requested - -## Children's Privacy - -ExcelMcp does not knowingly collect any information from anyone, including children under 13 years of age. - -## Changes to This Policy - -If we make changes to this privacy policy, we will update the "Last Updated" date above and publish the updated policy in our GitHub repository. - -## Contact - -For questions about this privacy policy or the ExcelMcp project: - -- **GitHub Issues:** https://github.com/sbroenne/mcp-server-excel/issues -- **Repository:** https://github.com/sbroenne/mcp-server-excel - ---- - -**Summary:** ExcelMcp processes your Excel files locally on your machine. We collect anonymous usage telemetry (tool usage, performance, errors) to improve the software, but never collect your file contents, file names, or personal information. diff --git a/Sbroenne.ExcelMcp.sln b/PptMcp.sln similarity index 86% rename from Sbroenne.ExcelMcp.sln rename to PptMcp.sln index 4edf951e..4fbf1408 100644 --- a/Sbroenne.ExcelMcp.sln +++ b/PptMcp.sln @@ -1,43 +1,43 @@ - + 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}") = "ExcelMcp.CLI", "src\ExcelMcp.CLI\ExcelMcp.CLI.csproj", "{3ACC5AFF-2C15-4546-BDDC-6785C9D79B72}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PptMcp.CLI", "src\PptMcp.CLI\PptMcp.CLI.csproj", "{3ACC5AFF-2C15-4546-BDDC-6785C9D79B72}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExcelMcp.CLI.Tests", "tests\ExcelMcp.CLI.Tests\ExcelMcp.CLI.Tests.csproj", "{B1234567-1234-1234-1234-123456789ABC}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PptMcp.CLI.Tests", "tests\PptMcp.CLI.Tests\PptMcp.CLI.Tests.csproj", "{B1234567-1234-1234-1234-123456789ABC}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExcelMcp.McpServer.Tests", "tests\ExcelMcp.McpServer.Tests\ExcelMcp.McpServer.Tests.csproj", "{C2345678-2345-2345-2345-23456789ABCD}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PptMcp.McpServer.Tests", "tests\PptMcp.McpServer.Tests\PptMcp.McpServer.Tests.csproj", "{C2345678-2345-2345-2345-23456789ABCD}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExcelMcp.Core", "src\ExcelMcp.Core\ExcelMcp.Core.csproj", "{819048D2-BF4F-4D6C-A7C3-B37869988003}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PptMcp.Core", "src\PptMcp.Core\PptMcp.Core.csproj", "{819048D2-BF4F-4D6C-A7C3-B37869988003}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExcelMcp.McpServer", "src\ExcelMcp.McpServer\ExcelMcp.McpServer.csproj", "{C9CF661A-9104-417F-A3EF-F9D5E4D59681}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PptMcp.McpServer", "src\PptMcp.McpServer\PptMcp.McpServer.csproj", "{C9CF661A-9104-417F-A3EF-F9D5E4D59681}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExcelMcp.Core.Tests", "tests\ExcelMcp.Core.Tests\ExcelMcp.Core.Tests.csproj", "{FFEFF3B4-C490-4255-8A47-C1FFA23A97D7}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PptMcp.Core.Tests", "tests\PptMcp.Core.Tests\PptMcp.Core.Tests.csproj", "{FFEFF3B4-C490-4255-8A47-C1FFA23A97D7}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExcelMcp.ComInterop", "src\ExcelMcp.ComInterop\ExcelMcp.ComInterop.csproj", "{022C9DE3-8F8F-4F3F-8F7A-7480932288C3}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PptMcp.ComInterop", "src\PptMcp.ComInterop\PptMcp.ComInterop.csproj", "{022C9DE3-8F8F-4F3F-8F7A-7480932288C3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExcelMcp.ComInterop.Tests", "tests\ExcelMcp.ComInterop.Tests\ExcelMcp.ComInterop.Tests.csproj", "{F5341F98-D6ED-4C71-8F75-676F59AA47B0}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PptMcp.ComInterop.Tests", "tests\PptMcp.ComInterop.Tests\PptMcp.ComInterop.Tests.csproj", "{F5341F98-D6ED-4C71-8F75-676F59AA47B0}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExcelMcp.Diagnostics.Tests", "tests\ExcelMcp.Diagnostics.Tests\ExcelMcp.Diagnostics.Tests.csproj", "{6C5A9F6E-5B5E-4A7A-9E98-6DF1F5A8B3A2}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PptMcp.Diagnostics.Tests", "tests\PptMcp.Diagnostics.Tests\PptMcp.Diagnostics.Tests.csproj", "{6C5A9F6E-5B5E-4A7A-9E98-6DF1F5A8B3A2}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExcelMcp.Service", "src\ExcelMcp.Service\ExcelMcp.Service.csproj", "{B0BF99C3-8F91-4E4F-AA2C-23F83FEA9545}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PptMcp.Service", "src\PptMcp.Service\PptMcp.Service.csproj", "{B0BF99C3-8F91-4E4F-AA2C-23F83FEA9545}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExcelMcp.Generators", "src\ExcelMcp.Generators\ExcelMcp.Generators.csproj", "{83F4216E-7E87-457D-9C15-2A95888295B1}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PptMcp.Generators", "src\PptMcp.Generators\PptMcp.Generators.csproj", "{83F4216E-7E87-457D-9C15-2A95888295B1}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExcelMcp.Generators.Shared", "src\ExcelMcp.Generators.Shared\ExcelMcp.Generators.Shared.csproj", "{1ED56112-6292-4C53-8FF8-C58A08CAB917}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PptMcp.Generators.Shared", "src\PptMcp.Generators.Shared\PptMcp.Generators.Shared.csproj", "{1ED56112-6292-4C53-8FF8-C58A08CAB917}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExcelMcp.Generators.Cli", "src\ExcelMcp.Generators.Cli\ExcelMcp.Generators.Cli.csproj", "{15CAF0EF-0DAA-4F9F-96C0-3FD9A344C1EB}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PptMcp.Generators.Cli", "src\PptMcp.Generators.Cli\PptMcp.Generators.Cli.csproj", "{15CAF0EF-0DAA-4F9F-96C0-3FD9A344C1EB}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExcelMcp.Build.Tasks", "src\ExcelMcp.Build.Tasks\ExcelMcp.Build.Tasks.csproj", "{CB3952BE-76FE-4CCA-88DF-DE3BB9DE836A}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PptMcp.Build.Tasks", "src\PptMcp.Build.Tasks\PptMcp.Build.Tasks.csproj", "{CB3952BE-76FE-4CCA-88DF-DE3BB9DE836A}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExcelMcp.SkillGeneration.Tests", "tests\ExcelMcp.SkillGeneration.Tests\ExcelMcp.SkillGeneration.Tests.csproj", "{0D116A1B-E515-4C74-B72A-89F9D25AC421}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PptMcp.SkillGeneration.Tests", "tests\PptMcp.SkillGeneration.Tests\PptMcp.SkillGeneration.Tests.csproj", "{0D116A1B-E515-4C74-B72A-89F9D25AC421}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExcelMcp.Generators.Mcp", "src\ExcelMcp.Generators.Mcp\ExcelMcp.Generators.Mcp.csproj", "{4D675883-7474-41DE-9144-5C5E62E92B0B}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PptMcp.Generators.Mcp", "src\PptMcp.Generators.Mcp\PptMcp.Generators.Mcp.csproj", "{4D675883-7474-41DE-9144-5C5E62E92B0B}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/README.md b/README.md index efb94f47..aed8dcbb 100644 --- a/README.md +++ b/README.md @@ -1,112 +1,110 @@ -# ExcelMcp - MCP Server for Microsoft Excel - -[![VS Code Marketplace Installs](https://img.shields.io/visual-studio-marketplace/i/sbroenne.excel-mcp?label=VS%20Code%20Installs)](https://marketplace.visualstudio.com/items?itemName=sbroenne.excel-mcp) -[![Downloads](https://img.shields.io/github/downloads/sbroenne/mcp-server-excel/total?label=GitHub%20Downloads)](https://github.com/sbroenne/mcp-server-excel/releases) -[![NuGet Downloads - MCP Server](https://img.shields.io/nuget/dt/Sbroenne.ExcelMcp.McpServer.svg?label=NuGet%20MCP%20Server)](https://www.nuget.org/packages/Sbroenne.ExcelMcp.McpServer) -[![NuGet Downloads - CLI](https://img.shields.io/nuget/dt/Sbroenne.ExcelMcp.CLI.svg?label=NuGet%20CLI)](https://www.nuget.org/packages/Sbroenne.ExcelMcp.CLI) - -[![Build MCP Server](https://github.com/sbroenne/mcp-server-excel/actions/workflows/build-mcp-server.yml/badge.svg)](https://github.com/sbroenne/mcp-server-excel/actions/workflows/build-mcp-server.yml) -[![Build CLI](https://github.com/sbroenne/mcp-server-excel/actions/workflows/build-cli.yml/badge.svg)](https://github.com/sbroenne/mcp-server-excel/actions/workflows/build-cli.yml) -[![Release](https://img.shields.io/github/v/release/sbroenne/mcp-server-excel)](https://github.com/sbroenne/mcp-server-excel/releases/latest) -[![NuGet MCP Server](https://img.shields.io/nuget/v/Sbroenne.ExcelMcp.McpServer.svg?label=MCP%20Server)](https://www.nuget.org/packages/Sbroenne.ExcelMcp.McpServer) -[![NuGet CLI](https://img.shields.io/nuget/v/Sbroenne.ExcelMcp.CLI.svg?label=CLI)](https://www.nuget.org/packages/Sbroenne.ExcelMcp.CLI) +# PptMcp - MCP Server for Microsoft PowerPoint [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![.NET](https://img.shields.io/badge/.NET-10-blue.svg)](https://dotnet.microsoft.com/download/dotnet/10.0) -[![Platform](https://img.shields.io/badge/platform-Windows-lightgrey.svg)](https://github.com/sbroenne/mcp-server-excel) +[![.NET](https://img.shields.io/badge/.NET-9-blue.svg)](https://dotnet.microsoft.com/download/dotnet/9.0) +[![Platform](https://img.shields.io/badge/platform-Windows-lightgrey.svg)](https://github.com/trsdn/mcp-server-ppt) [![Built with Copilot](https://img.shields.io/badge/Built%20with-GitHub%20Copilot-0366d6.svg)](https://copilot.github.com/) -**Automate Excel with AI - A Model Context Protocol (MCP) server for comprehensive Excel automation through conversational AI.** - -**MCP Server for Excel** enables AI assistants (GitHub Copilot, Claude, ChatGPT) to automate Excel through natural language commands. Automate Power Query, DAX measures, VBA macros, PivotTables, Charts, formatting, and data transformations (25 tools with 225 operations). +**Automate PowerPoint with AI — A Model Context Protocol (MCP) server for comprehensive PowerPoint automation through conversational AI.** -**🛡️ 100% Safe - Uses Excel's Native COM API** - Zero risk of file corruption. Unlike third-party libraries that manipulate `.xlsx` files directly, this project uses Excel's official API ensuring complete safety and compatibility. +**MCP Server for PowerPoint** enables AI assistants (GitHub Copilot, Claude, ChatGPT) to automate PowerPoint through natural language commands. Manage slides, shapes, text, charts, tables, animations, transitions, VBA macros, and more (32 tools with 170 operations). -**💡 Interactive Development** - See results instantly in Excel. Create a query, run it, inspect the output, refine and repeat. Excel becomes your AI-powered workspace for rapid development and testing. +**🛡️ 100% Safe — Uses PowerPoint's Native COM API** — Zero risk of file corruption. Uses PowerPoint's official COM API ensuring complete safety and compatibility. -**🧪 LLM-Tested Quality** - Tool behavior validated with real LLM workflows using [pytest-aitest](https://github.com/sbroenne/pytest-aitest). We test that LLMs correctly understand and use our tools. +**💡 Interactive Development** — See results instantly in PowerPoint. Add slides, create charts, format text, and iterate. PowerPoint becomes your AI-powered workspace. **Technical Requirements:** -- ⚠️ **Windows Only** - COM interop is Windows-specific -- ⚠️ **Excel Required** - Microsoft Excel 2016 or later must be installed -- ⚠️ **Desktop Environment** - Controls actual Excel process (not for server-side processing) +- ⚠️ **Windows Only** — COM interop is Windows-specific +- ⚠️ **PowerPoint Required** — Microsoft PowerPoint 2016 or later must be installed +- ⚠️ **Desktop Environment** — Controls actual PowerPoint process (not for server-side processing) ## 🎯 What You Can Do -**25 specialized tools with 225 operations:** - -- 🔄 **Power Query** (1 tool, 11 ops) - Atomic workflows, M code management, load destinations -- 📊 **Data Model/DAX** (2 tools, 18 ops) - Measures with auto-formatted DAX, relationships, model structure -- 🎨 **Excel Tables** (2 tools, 27 ops) - Lifecycle, filtering, sorting, structured references -- 📈 **PivotTables** (3 tools, 30 ops) - Creation, fields, aggregations, calculated members/fields -- 📉 **Charts** (2 tools, 26 ops) - Create, configure, series, formatting, data labels, trendlines -- 📝 **VBA** (1 tool, 6 ops) - Modules, execution, version control -- 📋 **Ranges** (4 tools, 42 ops) - Values, formulas, formatting, validation, protection -- 📄 **Worksheets** (2 tools, 16 ops) - Lifecycle, colors, visibility, cross-workbook moves -- 🔌 **Connections** (1 tool, 9 ops) - OLEDB/ODBC management and refresh -- 🏷️ **Named Ranges** (1 tool, 6 ops) - Parameters and configuration -- 📁 **Files** (1 tool, 6 ops) - Session management, workbook creation, IRM/AIP-protected file support -- 🧮 **Calculation Mode** (1 tool, 3 ops) - Get/set calculation mode and trigger recalculation -- 🎚️ **Slicers** (1 tool, 8 ops) - Interactive filtering for PivotTables and Tables -- 🎨 **Conditional Formatting** (1 tool, 2 ops) - Rules and clearing -- 📸 **Screenshot** (1 tool, 2 ops) - Capture ranges/sheets as PNG for LLM visual verification -- 🪧 **Window Management** (1 tool, 9 ops) - Show/hide Excel, arrange, position, status bar feedback - -📚 **[Complete Feature Reference →](FEATURES.md)** - Detailed documentation of all 225 operations +**32 specialized tools with 170 operations:** + +- 📄 **Slides** (1 tool, 8 ops) — Create, duplicate, move, delete, apply layouts, set name +- 🔷 **Shapes** (1 tool, 19 ops) — Add, move, resize, fill, line, shadow, rotation, z-order, grouping, copy, connectors, merge, flip, duplicate +- 📝 **Text** (1 tool, 5 ops) — Get/set text, find, replace, format +- 📊 **Charts** (1 tool, 5 ops) — Create charts, set title, type, get info, delete +- 📋 **Slide Tables** (1 tool, 8 ops) — Create, read, write cells, add/delete rows and columns, merge cells +- 🎬 **Animations** (1 tool, 4 ops) — List, add, remove, clear animation effects +- 🔄 **Transitions** (1 tool, 4 ops) — Get, set, remove, copy to all slides +- 🎨 **Design/Themes** (1 tool, 4 ops) — List designs, apply themes, get theme colors, list color schemes +- 🖼️ **Images** (1 tool, 1 op) — Insert images with position and size control +- 📝 **Notes** (1 tool, 4 ops) — Get, set, clear, append speaker notes +- 🏷️ **Sections** (1 tool, 4 ops) — List, add, rename, delete presentation sections +- 🔗 **Hyperlinks** (1 tool, 4 ops) — Add, read, list, remove hyperlinks +- 📺 **Slideshow** (1 tool, 4 ops) — Start, stop, navigate, get status +- 🎭 **Slide Masters** (1 tool, 1 op) — List masters and layouts +- 📤 **Export** (1 tool, 5 ops) — PDF, slide images, video (MP4), print, save-as (7 formats) +- 📝 **VBA** (1 tool, 5 ops) — List, view, import, delete, run macros +- 🎥 **Media** (1 tool, 3 ops) — Insert audio/video, get media info +- 🪟 **Window** (1 tool, 5 ops) — Get info, minimize, restore, maximize, set zoom +- 📁 **Files** (1 tool, 1 op) — File validation and info +- 📑 **Document Properties** (1 tool, 2 ops) — Get/set title, author, subject, etc. +- 💬 **Comments** (1 tool, 4 ops) — Add, list, delete, clear slide comments +- 📌 **Placeholders** (1 tool, 2 ops) — List placeholders, set placeholder text +- 🎨 **Slide Background** (1 tool, 4 ops) — Get info, set solid color, set image, reset to master +- 📋 **Headers & Footers** (1 tool, 2 ops) — Get/set footer text, slide numbers, date +- 🧩 **SmartArt** (1 tool, 2 ops) — Get diagram info, add nodes +- 📐 **Shape Alignment** (1 tool, 2 ops) — Align and distribute shapes on slides +- 🎪 **Custom Shows** (1 tool, 3 ops) — Create, list, delete custom slide shows +- 📏 **Page Setup** (1 tool, 2 ops) — Get/set slide size and orientation +- 📥 **Slide Import** (1 tool, 1 op) — Import slides from another .pptx file +- 🏷️ **Tags** (1 tool, 3 ops) — Custom metadata on slides and shapes + +📚 **[Complete Feature Reference →](FEATURES.md)** — Detailed documentation of all 156 operations ## 💬 Example Prompts -**Create & Populate Data:** -- *"Create a new Excel file called SalesTracker.xlsx with a table for Date, Product, Quantity, Unit Price, and Total with sample data"* -- *"Put this data in A1:C4 - Name, Age, City / Alice, 30, Seattle / Bob, 25, Portland"* -- *"Add a formula column that calculates Quantity times Unit Price"* +**Create & Build Presentations:** +- *"Create a new PowerPoint presentation called QuarterlyReport.pptx with a title slide"* +- *"Add 5 slides with a 'Title and Content' layout"* +- *"Insert a company logo image on the first slide"* -**Analysis & Visualization:** -- *"Create a PivotTable from this data showing total sales by Product, then add a bar chart"* -- *"Use Power Query to import products.csv, load it to the Data Model, and create a measure for Total Revenue"* -- *"Create a slicer for the Region field so I can filter the PivotTable interactively"* -- *"Create a relationship between the Orders and Products tables using ProductID"* +**Content & Formatting:** +- *"Add a textbox on slide 2 with the text 'Q1 Revenue Summary' in bold 24pt Arial"* +- *"Create a table on slide 3 with columns for Region, Q1, Q2, Q3, Q4"* +- *"Set the shape fill color to #0078D4 and add a 2pt border"* -**Formatting & Styling:** -- *"Format the Price column as currency and highlight values over $500 in green"* -- *"Convert this range to an Excel Table with a blue style and add a totals row"* -- *"Make the headers bold with a dark background and auto-fit column widths"* +**Charts & Visuals:** +- *"Create a bar chart on slide 4 showing quarterly revenue data"* +- *"Set the chart title to 'Revenue by Quarter'"* +- *"Add an entrance animation to the chart shape"* **Automation:** -- *"Export all Power Query M code to files for version control"* -- *"Run the UpdatePrices macro"* -- *"Show me Excel while you work"* - watch changes in real-time +- *"Export the presentation as PDF"* +- *"Run the FormatAllSlides macro"* +- *"Show me PowerPoint while you work"* — watch changes in real-time -**🪟 Agent Mode — Watch AI Work in Excel:** -- *"Show me Excel side-by-side while you build this dashboard"* - real-time visibility -- *"Let me watch while you create the chart"* - AI asks your preference, then shows Excel -- Status bar shows live progress: *"ExcelMcp: Building PivotTable from Sales data..."* +**🪟 Agent Mode — Watch AI Work in PowerPoint:** +- *"Show me PowerPoint side-by-side while you build this presentation"* — real-time visibility +- *"Let me watch while you create the slides"* +- Status bar shows live progress: *"PptMcp: Creating chart on slide 4..."* ## 👥 Who Should Use This? **Perfect for:** -- ✅ **Data analysts** automating repetitive Excel workflows -- ✅ **Developers** building Excel-based data solutions -- ✅ **Business users** managing complex Excel workbooks -- ✅ **Teams** maintaining Power Query/VBA/DAX code in Git +- ✅ **Presenters** automating repetitive PowerPoint workflows +- ✅ **Developers** building PowerPoint-based reporting solutions +- ✅ **Business users** managing complex presentation decks +- ✅ **Teams** maintaining presentation templates and VBA macros **Not suitable for:** -- ❌ Server-side data processing (use libraries like ClosedXML, EPPlus instead) -- ❌ Linux/macOS users (Windows + Excel installation required) -- ❌ High-volume batch operations (consider Excel-free alternatives) +- ❌ Server-side processing (use libraries like Open XML SDK instead) +- ❌ Linux/macOS users (Windows + PowerPoint installation required) +- ❌ High-volume batch operations (consider PowerPoint-free alternatives) ## 🚀 Quick Start | Platform | Installation | |----------|-------------| -| **VS Code** | [Install Extension](https://marketplace.visualstudio.com/items?itemName=sbroenne.excel-mcp) (one-click, recommended) | -| **Claude Desktop** | Download `.mcpb` from [latest release](https://github.com/sbroenne/mcp-server-excel/releases/latest) | -| **Any MCP Client** | `dotnet tool install --global Sbroenne.ExcelMcp.McpServer` then `npx add-mcp "mcp-excel" --name excel-mcp` | +| **Any MCP Client** | `dotnet tool install --global PptMcp.McpServer` | | **Details** | 📖 [Installation Guide](docs/INSTALLATION.md) | -**⚠️ Important:** Close all Excel files before using. The server requires exclusive access to workbooks during automation. +**⚠️ Important:** Close all PowerPoint files before using. The server requires exclusive access to presentations during automation. ## 🔧 CLI vs MCP Server @@ -115,103 +113,76 @@ This package provides both **CLI** and **MCP Server** interfaces. Choose based o | Interface | Best For | Why | |-----------|----------|-----| -| **CLI** (`excelcli`) | Coding agents (Copilot, Cursor, Windsurf) | **64% fewer tokens** - single tool, no large schemas. Auto-generated from Core code, ensuring 1:1 feature parity. | -| **MCP Server** | Conversational AI (Claude Desktop, VS Code Chat) | Rich tool discovery, persistent connection. Better for interactive, exploratory workflows. | - -**⚡ CLI Commands:** Generated automatically from Core service definitions using Roslyn source generators. All 22 command categories maintain exact 1:1 parity with MCP tools through shared code generation. See [code generation docs](docs/DEVELOPMENT.md#-cli-command-code-generation) for details. - -
-📊 Benchmark Results (same task, same model) - -| Metric | CLI | MCP Server | Winner | -|--------|-----|------------|--------| -| **Tokens** | ~59K | ~163K | 🏆 CLI (64% fewer) | - -**Key insight:** MCP sends 23 tool schemas to the LLM on each request (~100K+ tokens). - -
+| **CLI** (`pptcli`) | Coding agents (Copilot, Cursor, Windsurf) | Fewer tokens — single tool, no large schemas. | +| **MCP Server** | Conversational AI (Claude Desktop, VS Code Chat) | Rich tool discovery, persistent connection. | **Manual Installation:** ```powershell -# Step 1: Install MCP Server and CLI -dotnet tool install --global Sbroenne.ExcelMcp.McpServer -dotnet tool install --global Sbroenne.ExcelMcp.CLI - -# Step 2: Auto-configure all your coding agents (requires Node.js) -npx add-mcp "mcp-excel" --name excel-mcp -``` - -> ⚠️ **Step 2 requires [Node.js](https://nodejs.org/)** for `npx`. Install with `winget install OpenJS.NodeJS.LTS` if needed. - -```powershell -# Optional: Install agent skills for better AI guidance -npx skills add sbroenne/mcp-server-excel --skill excel-cli # Coding agents -npx skills add sbroenne/mcp-server-excel --skill excel-mcp # Conversational AI +# Install MCP Server and CLI +dotnet tool install --global PptMcp.McpServer +dotnet tool install --global PptMcp.CLI ``` -> 💡 **Skills provide AI guidance** - The CLI skill is highly recommended (agents don't work perfectly with CLI without it). The MCP skill is recommended - it adds workflow best practices and reduces token usage. +## ⚙️ How It Works — COM Automation & Unified Service Architecture -## ⚙️ How It Works - COM Automation & Unified Service Architecture +**PptMcp uses Windows COM automation to control the actual PowerPoint application (not just .pptx files).** -**ExcelMcp uses Windows COM automation to control the actual Excel application (not just .xlsx files).** - -Both the **MCP Server** and **CLI** communicate with a shared **ExcelMCP Service** that manages Excel sessions. This unified architecture enables: +Both the **MCP Server** and **CLI** communicate with a shared **PptMcp Service** that manages PowerPoint sessions. This unified architecture enables: ``` ┌─────────────────────┐ ┌─────────────────────┐ -│ MCP Server │ │ CLI (excelcli) │ +│ MCP Server │ │ CLI (pptcli) │ │ (AI assistants) │ │ (coding agents) │ └─────────┬───────────┘ └─────────┬───────────┘ │ │ └──────────┬────────────────┘ ▼ ┌─────────────────────────┐ - │ ExcelMCP Service │ + │ PptMcp Service │ │ (shared session mgmt) │ └─────────┬───────────────┘ ▼ ┌─────────────────────────┐ - │ Excel COM API │ - │ (Excel.Application) │ + │ PowerPoint COM API │ + │ (PowerPoint.Application)│ └─────────────────────────┘ ``` **Key Benefits:** -- ✅ **Shared Sessions** - CLI and MCP Server can access the same open workbooks -- ✅ **Single Excel Instance** - No duplicate Excel processes or file locks -- ✅ **System Tray UI** - Monitor active sessions via the ExcelMCP tray icon +- ✅ **Shared Sessions** — CLI and MCP Server can access the same open presentations +- ✅ **Single PowerPoint Instance** — No duplicate processes or file locks +- ✅ **System Tray UI** — Monitor active sessions via the PptMcp tray icon -**💡 Tip: Watch Excel While AI Works** -By default, Excel runs hidden for faster automation. To see changes in real-time, just ask: -- *"Show me Excel while you work"* +**💡 Tip: Watch PowerPoint While AI Works** +By default, PowerPoint runs hidden for faster automation. To see changes in real-time, just ask: +- *"Show me PowerPoint while you work"* - *"Let me watch what you're doing"* -- *"Open Excel so I can see the changes"* +- *"Open PowerPoint so I can see the changes"* -The AI will display the Excel window so you can watch every operation happen live - great for learning or verifying changes! +The AI will display the PowerPoint window so you can watch every operation happen live! ## 📋 Additional Information -📚 **[CLI Guide →](src/ExcelMcp.CLI/README.md)** | **[CLI Skill for Agents →](skills/excel-cli/SKILL.md)** | **[MCP Server Guide →](src/ExcelMcp.McpServer/README.md)** | **[All Agent Skills →](skills/README.md)** +📚 **[CLI Guide →](src/PptMcp.CLI/README.md)** | **[CLI Skill for Agents →](skills/ppt-cli/SKILL.md)** | **[MCP Server Guide →](src/PptMcp.McpServer/README.md)** | **[All Agent Skills →](skills/README.md)** **License:** MIT License - see [LICENSE](LICENSE) file -**Privacy:** See [PRIVACY.md](PRIVACY.md) for our privacy policy - **Contributing:** See [CONTRIBUTING.md](docs/CONTRIBUTING.md) for guidelines **Built With:** This entire project was developed using GitHub Copilot AI assistance - mainly with Claude but lately with Auto-mode. **Acknowledgments:** -- Microsoft Excel Team - For comprehensive COM automation APIs -- Model Context Protocol community - For the AI integration standard -- Open Source Community - For inspiration and best practices +- **[Stefan Broenner (sbroenne)](https://github.com/sbroenne)** — Original author and creator of the [upstream mcp-server-ppt](https://github.com/sbroenne/mcp-server-ppt) project. This fork builds on his excellent foundation for PowerPoint COM automation via MCP. +- Microsoft PowerPoint Team — For comprehensive COM automation APIs +- Model Context Protocol community — For the AI integration standard +- Open Source Community — For inspiration and best practices ## Related Projects -Other projects by the author: +Upstream projects by Stefan Broenner: +- [mcp-server-ppt (upstream)](https://github.com/sbroenne/mcp-server-ppt) — Original MCP Server for PowerPoint by Stefan Broenner - [pytest-aitest](https://github.com/sbroenne/pytest-aitest) — LLM-powered testing framework for AI agents -- [Windows MCP Server](https://windowsmcpserver.dev/) — AI-powered Windows automation via MCP - [OBS Studio MCP Server](https://github.com/sbroenne/mcp-server-obs) — AI-powered OBS Studio automation - [HeyGen MCP Server](https://github.com/sbroenne/heygen-mcp) — MCP server for HeyGen AI video generation diff --git a/SECURITY.md b/SECURITY.md index 3cd7b16a..9ce4b084 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Supported Versions -We actively support the following versions of ExcelMcp with security updates: +We actively support the following versions of PptMcp with security updates: | Version | Supported | Status | | ------- | ------------------ | ------ | @@ -12,13 +12,13 @@ We actively support the following versions of ExcelMcp with security updates: ## Security Features -ExcelMcp includes several security measures: +PptMcp includes several security measures: ### Input Validation - **Path Traversal Protection**: All file paths are validated with `Path.GetFullPath()` - **File Size Limits**: 1GB maximum file size to prevent DoS attacks -- **Extension Validation**: Only `.xlsx` and `.xlsm` files are accepted +- **Extension Validation**: Only `.pptx` and `.pptm` files are accepted - **Path Length Validation**: Maximum 32,767 characters (Windows limit) ### Code Analysis @@ -29,17 +29,17 @@ ExcelMcp includes several security measures: ### COM Security -- **Controlled Excel Automation**: Excel.Application runs with `Visible=false` and `DisplayAlerts=false` +- **Controlled PowerPoint Automation**: PowerPoint.Application runs with `Visible=false` and `DisplayAlerts=false` - **Resource Cleanup**: Comprehensive COM object disposal and garbage collection -- **No Remote Connections**: Only local Excel automation supported +- **No Remote Connections**: Only local PowerPoint automation supported -### ExcelMCP Service Security +### PptMcp Service Security -The ExcelMCP Service manages Excel COM automation sessions: +The PptMcp Service manages PowerPoint COM automation sessions: **MCP Server**: The service runs fully **in-process** — no inter-process communication. There is no attack surface beyond the MCP Server process itself. -**CLI**: The CLI daemon uses a **Windows named pipe** (`excelmcp-cli-{USER_SID}`) for communication between CLI commands and the daemon process: +**CLI**: The CLI daemon uses a **Windows named pipe** (`PptMcp-cli-{USER_SID}`) for communication between CLI commands and the daemon process: | Protection | Status | Description | |------------|--------|-------------| @@ -50,7 +50,7 @@ The ExcelMCP Service manages Excel COM automation sessions: **What This Means:** -1. **Same-user access**: Any application running under your Windows user account can connect to the CLI daemon and execute Excel operations. This is by design, similar to how Docker and database servers work. +1. **Same-user access**: Any application running under your Windows user account can connect to the CLI daemon and execute PowerPoint operations. This is by design, similar to how Docker and database servers work. 2. **No cross-user access**: User A cannot connect to User B's CLI daemon. Each user has a separate named pipe with their SID. @@ -58,8 +58,8 @@ The ExcelMCP Service manages Excel COM automation sessions: **Security Implications:** -- If malware runs under your user account, it could theoretically connect to the CLI daemon and control Excel -- However, such malware could already control Excel directly (or do anything else you can do) +- If malware runs under your user account, it could theoretically connect to the CLI daemon and control PowerPoint +- However, such malware could already control PowerPoint directly (or do anything else you can do) - The service does not elevate privileges or provide capabilities beyond what the user already has ### Dependency Management @@ -82,15 +82,15 @@ Report security vulnerabilities using one of these methods: **Preferred Method: GitHub Security Advisories** -1. Go to +1. Go to 2. Click "Report a vulnerability" 3. Fill out the advisory form with detailed information **Alternative: GitHub Direct Message** -Contact the maintainer via GitHub: [@sbroenne](https://github.com/sbroenne) +Contact the maintainer via GitHub: [@trsdn](https://github.com/trsdn) -Subject: `[SECURITY] ExcelMcp Vulnerability Report` +Subject: `[SECURITY] PptMcp Vulnerability Report` ### 3. Information to Include @@ -108,7 +108,7 @@ Example: Vulnerability: Path traversal in file operations Impact: Attacker could read/write files outside intended directory Affected Versions: 1.0.0 - 1.0.2 -PoC: ExcelMcp.exe pq-export "../../../etc/passwd" "query" +PoC: PptMcp.exe pq-export "../../../etc/passwd" "query" Suggested Fix: Validate resolved paths are within allowed directories ``` @@ -137,7 +137,7 @@ We follow responsible disclosure practices: ### MCP Server Security -- **Validate AI Requests**: Review Excel operations requested by AI assistants +- **Validate AI Requests**: Review PowerPoint operations requested by AI assistants - **File Path Restrictions**: Only allow MCP Server access to specific directories - **Audit Logs**: Monitor MCP Server operations in logs - **Trust Configuration**: Only enable VBA trust when necessary @@ -145,9 +145,9 @@ We follow responsible disclosure practices: ### CLI Security - **Script Validation**: Review automation scripts before execution -- **File Permissions**: Ensure Excel files have appropriate permissions +- **File Permissions**: Ensure PowerPoint files have appropriate permissions - **Isolated Environment**: Run in sandboxed environment when processing untrusted files -- **Excel Security Settings**: Maintain appropriate Excel macro security settings +- **PowerPoint Security Settings**: Maintain appropriate PowerPoint macro security settings ### Development Security @@ -158,11 +158,11 @@ We follow responsible disclosure practices: ## Known Security Considerations -### Excel COM Automation +### PowerPoint COM Automation -- **Local Only**: ExcelMcp only supports local Excel automation -- **Windows Only**: Requires Windows with Excel installed -- **Excel Process**: Creates Excel.Application COM objects +- **Local Only**: PptMcp only supports local PowerPoint automation +- **Windows Only**: Requires Windows with PowerPoint installed +- **PowerPoint Process**: Creates PowerPoint.Application COM objects - **Macro Security**: VBA operations require user consent via `setup-vba-trust` ### File System Access @@ -174,16 +174,16 @@ We follow responsible disclosure practices: ### AI Integration (MCP Server) - **Trusted AI Assistants**: Only use with trusted AI platforms -- **Request Validation**: Review operations before Excel executes them -- **Sensitive Data**: Avoid exposing workbooks with sensitive data to AI assistants +- **Request Validation**: Review operations before PowerPoint executes them +- **Sensitive Data**: Avoid exposing presentations with sensitive data to AI assistants - **Audit Trail**: MCP Server logs all operations ## Security Updates Security updates are published through: -- **GitHub Security Advisories**: -- **Release Notes**: +- **GitHub Security Advisories**: +- **Release Notes**: - **NuGet Advisories**: Package vulnerabilities shown in NuGet Subscribe to repository notifications to receive security alerts. @@ -206,8 +206,8 @@ Subscribe to repository notifications to receive security alerts. ## Security Contacts -- **GitHub Security**: -- **Maintainer**: @sbroenne +- **GitHub Security**: +- **Maintainer**: @trsdn ## Additional Resources @@ -227,4 +227,4 @@ Subscribe to repository notifications to receive security alerts. **Last Updated**: 2026-03-03 -Thank you for helping keep ExcelMcp and its users safe! +Thank you for helping keep PptMcp and its users safe! diff --git a/docs/ADR-001-NO-UNIT-TESTS.md b/docs/ADR-001-NO-UNIT-TESTS.md index 0b6ad19e..7f20cfde 100644 --- a/docs/ADR-001-NO-UNIT-TESTS.md +++ b/docs/ADR-001-NO-UNIT-TESTS.md @@ -1,4 +1,4 @@ -# ADR-001: Why ExcelMcp Has No Traditional Unit Tests +# ADR-001: Why PptMcp Has No Traditional Unit Tests **Status**: Accepted **Date**: 2025-11-02 @@ -9,7 +9,7 @@ ## Context and Problem Statement -ExcelMcp is a COM automation library that wraps Excel's COM API. During code review, the question inevitably arises: **"Why don't you have unit tests?"** +PptMcp is a COM automation library that wraps PowerPoint's COM API. During code review, the question inevitably arises: **"Why don't you have unit tests?"** This ADR documents our architectural decision and the reasoning behind our testing strategy. @@ -17,36 +17,36 @@ This ADR documents our architectural decision and the reasoning behind our testi ## Decision -**We do NOT write traditional unit tests for ExcelMcp.** Our test suite consists exclusively of **integration tests** that interact with real Excel instances via COM automation. +**We do NOT write traditional unit tests for PptMcp.** Our test suite consists exclusively of **integration tests** that interact with real PowerPoint instances via COM automation. ### What We DON'T Do -❌ Mock Excel COM objects +❌ Mock PowerPoint COM objects ❌ Write unit tests for business logic ❌ Test internal methods in isolation ❌ Separate "unit" from "integration" concerns ### What We DO Do -✅ Write comprehensive integration tests against real Excel -✅ Test every operation with actual Excel workbooks +✅ Write comprehensive integration tests against real PowerPoint +✅ Test every operation with actual PowerPoint presentations ✅ Verify behavior through COM API interactions -✅ Run tests on CI/CD with Excel installed (Azure self-hosted runner) +✅ Run tests on CI/CD with PowerPoint installed (Azure self-hosted runner) --- ## Rationale -### 1. Excel COM Cannot Be Meaningfully Mocked +### 1. PowerPoint COM Cannot Be Meaningfully Mocked -**The Problem**: Excel's COM API is the "database" we're automating against. Consider this code: +**The Problem**: PowerPoint's COM API is the "database" we're automating against. Consider this code: ```csharp -public async Task CreateWorksheet(IExcelBatch batch, string sheetName) +public async Task CreateSlide(IPptBatch batch, string sheetName) { return await batch.ExecuteAsync((ctx, ct) => { - dynamic sheets = ctx.Book.Worksheets; // COM object + dynamic sheets = ctx.Presentation.Slides; // COM object dynamic newSheet = sheets.Add(); // COM method newSheet.Name = sheetName; // COM property return new OperationResult { Success = true }; @@ -59,21 +59,21 @@ public async Task CreateWorksheet(IExcelBatch batch, string she ```csharp // Option 1: Mock the COM object var mockBook = new Mock(); // ❌ Cannot mock dynamic COM objects -mockBook.Setup(b => b.Worksheets).Returns(...); // ❌ Runtime binding fails +mockBook.Setup(b => b.Slides).Returns(...); // ❌ Runtime binding fails -// Option 2: Test without Excel +// Option 2: Test without PowerPoint [Fact] -public void CreateWorksheet_ReturnsSuccess() +public void CreateSlide_ReturnsSuccess() { - var result = CreateWorksheet(null!, "Test"); // ❌ What are we testing? + var result = CreateSlide(null!, "Test"); // ❌ What are we testing? Assert.True(result.Success); // ❌ This proves nothing! } ``` **The Truth**: The ONLY way to verify this code works is to: -1. Open a real Excel instance +1. Open a real PowerPoint instance 2. Call the real COM API -3. Verify the worksheet actually exists in Excel +3. Verify the slide actually exists in PowerPoint **That's an integration test by definition.** @@ -88,7 +88,7 @@ In COM automation architecture: - **Integration tests** test business logic AND COM interaction (these ARE our unit tests) - **E2E tests** don't exist (we ARE the library, not an application) -**Analogy**: ExcelMcp is like a database driver (e.g., Npgsql for PostgreSQL): +**Analogy**: PptMcp is like a database driver (e.g., Npgsql for PostgreSQL): - You don't mock `DbConnection` to test SQL queries - You test against a real database instance - The "integration test" IS the unit test @@ -102,7 +102,7 @@ This pattern is **normal and correct** for COM/browser/external system automatio | **Selenium WebDriver** | Browser DOM | Integration tests against real browsers | | **Playwright** | Browser automation | Integration tests with browser instances | | **AWS SDK** | Cloud services | Integration tests against AWS (or LocalStack) | -| **ExcelMcp** | Excel COM | Integration tests against Excel instances | +| **PptMcp** | PowerPoint COM | Integration tests against PowerPoint instances | **None of these libraries have meaningful unit tests** for their core automation logic. They all test against the real external system. @@ -174,48 +174,48 @@ public void RangeValueResult_SerializesToJson() ### What Our Integration Tests Actually Test -**Scenario**: Create a worksheet named "Sales" +**Scenario**: Create a slide named "Sales" ```csharp [Fact] -public async Task CreateWorksheet_ValidName_CreatesSheet() +public async Task CreateSlide_ValidName_CreatesSheet() { // Arrange - var testFile = await CreateUniqueTestFile(".xlsx"); + var testFile = await CreateUniqueTestFile(".pptx"); // Act - await using var batch = await ExcelSession.BeginBatchAsync(testFile); + await using var batch = await PptSession.BeginBatchAsync(testFile); var result = await _commands.CreateAsync(batch, "Sales"); await batch.Save(); // Assert - Round-trip validation Assert.True(result.Success); - await using var batch2 = await ExcelSession.BeginBatchAsync(testFile); + await using var batch2 = await PptSession.BeginBatchAsync(testFile); var list = await _commands.ListAsync(batch2); Assert.Contains(list.Items, s => s.Name == "Sales"); } ``` **What this ACTUALLY tests**: -1. ✅ Excel session management (ExcelSession.BeginBatchAsync) -2. ✅ COM object lifecycle (Workbooks.Open, Worksheets.Add) -3. ✅ Batch transaction handling (IExcelBatch) +1. ✅ PowerPoint session management (PptSession.BeginBatchAsync) +2. ✅ COM object lifecycle (Presentations.Open, Slides.Add) +3. ✅ Batch transaction handling (IPptBatch) 4. ✅ Error handling (COM exceptions) 5. ✅ Resource cleanup (IDisposable, COM release) -6. ✅ Persistence (workbook.Save) -7. ✅ Re-opening workbooks (validates saved state) -8. ✅ Business logic (worksheet creation) +6. ✅ Persistence (presentation.Save) +7. ✅ Re-opening presentations (validates saved state) +8. ✅ Business logic (slide creation) 9. ✅ API contract (ISheetCommands interface) -**A unit test could verify**: None of the above (requires real Excel). +**A unit test could verify**: None of the above (requires real PowerPoint). ### Test Statistics - **Integration Tests**: ~200+ tests covering all operations - **Execution Time**: 10-20 minutes (acceptable for CI/CD) - **Coverage**: All production code paths -- **False Positives**: Near zero (tests against real Excel) +- **False Positives**: Near zero (tests against real PowerPoint) --- @@ -223,7 +223,7 @@ public async Task CreateWorksheet_ValidName_CreatesSheet() ### Positive -✅ **Tests verify real behavior** - We test what actually happens in Excel, not mocked abstractions +✅ **Tests verify real behavior** - We test what actually happens in PowerPoint, not mocked abstractions ✅ **High confidence** - If tests pass, the code works in production ✅ **No mock maintenance** - No complex mock setup that becomes outdated ✅ **Catches integration bugs** - We discover COM quirks (e.g., 1-based indexing, Type 3/4 connection discrepancy) @@ -232,39 +232,39 @@ public async Task CreateWorksheet_ValidName_CreatesSheet() ### Negative ⚠️ **Slower tests** - 10-20 minutes vs seconds for unit tests -⚠️ **Requires Excel** - CI/CD needs Windows + Excel (Azure self-hosted runner) -⚠️ **Resource intensive** - Each test opens/closes Excel COM instance -⚠️ **Cannot run on Linux** - Excel COM is Windows-only +⚠️ **Requires PowerPoint** - CI/CD needs Windows + PowerPoint (Azure self-hosted runner) +⚠️ **Resource intensive** - Each test opens/closes PowerPoint COM instance +⚠️ **Cannot run on Linux** - PowerPoint COM is Windows-only ### Mitigation Strategies **For slow tests**: - Run tests in parallel (xUnit parallelization) -- Cache Excel instances where safe +- Cache PowerPoint instances where safe - Use OnDemand trait for expensive tests - Optimize CI/CD with dedicated Windows runners -**For Excel dependency**: +**For PowerPoint dependency**: - Azure self-hosted runner with Office 365 installed -- Local development requires Excel (documented in CONTRIBUTING.md) +- Local development requires PowerPoint (documented in CONTRIBUTING.md) - Pre-commit hooks run quick validation only --- ## Alternatives Considered -### Alternative 1: Mock Excel COM Objects +### Alternative 1: Mock PowerPoint COM Objects **Rejected** because: - `dynamic` COM objects cannot be meaningfully mocked -- Mocks would just verify our mock setup, not real Excel behavior -- Excel's COM API has quirks (1-based indexing, async RefreshAll issues) that mocks wouldn't catch +- Mocks would just verify our mock setup, not real PowerPoint behavior +- PowerPoint's COM API has quirks (1-based indexing, async RefreshAll issues) that mocks wouldn't catch ### Alternative 2: Record/Replay COM Interactions **Rejected** because: -- Fragile (breaks when Excel updates) -- Doesn't test actual Excel state +- Fragile (breaks when PowerPoint updates) +- Doesn't test actual PowerPoint state - High maintenance burden - Doesn't verify persistence (save/reload) @@ -272,13 +272,13 @@ public async Task CreateWorksheet_ValidName_CreatesSheet() **Rejected** because: - There IS no business logic separate from COM interaction -- Our "business logic" IS calling Excel COM methods correctly +- Our "business logic" IS calling PowerPoint COM methods correctly - Would create artificial abstraction layers with no value -### Alternative 4: Test Against Excel Interop Primary Assemblies +### Alternative 4: Test Against PowerPoint Interop Primary Assemblies **Rejected** because: -- Still requires Excel installed +- Still requires PowerPoint installed - PIAs are just type definitions, not implementation - Doesn't reduce test execution time - We use late binding (`dynamic`) intentionally for flexibility @@ -289,11 +289,11 @@ public async Task CreateWorksheet_ValidName_CreatesSheet() We **would** write unit tests for: -1. **Pure algorithms** - If we had complex calculations independent of Excel (we don't) +1. **Pure algorithms** - If we had complex calculations independent of PowerPoint (we don't) 2. **Custom protocols** - If we implemented custom serialization (MCP SDK handles this) 3. **Complex state machines** - If we had stateful logic beyond COM (we don't) -**Current reality**: 100% of our logic involves Excel COM interaction, so 100% of our tests are integration tests. +**Current reality**: 100% of our logic involves PowerPoint COM interaction, so 100% of our tests are integration tests. --- @@ -301,12 +301,12 @@ We **would** write unit tests for: When reviewers ask "Why no unit tests?", respond: -> **ExcelMcp is a COM automation library.** We test against real Excel instances because: +> **PptMcp is a COM automation library.** We test against real PowerPoint instances because: > -> 1. **Excel COM cannot be mocked** - Dynamic COM objects don't support traditional mocking frameworks +> 1. **PowerPoint COM cannot be mocked** - Dynamic COM objects don't support traditional mocking frameworks > 2. **Integration tests ARE our unit tests** - We test business logic (COM interaction) in the only way possible > 3. **Industry standard** - Selenium, Playwright, AWS SDK all use the same pattern -> 4. **High confidence** - Tests verify actual Excel behavior, not mock abstractions +> 4. **High confidence** - Tests verify actual PowerPoint behavior, not mock abstractions > > See `docs/ADR-001-NO-UNIT-TESTS.md` for full rationale. @@ -341,7 +341,7 @@ When reviewers ask "Why no unit tests?", respond: **Superseded by**: N/A **Last Reviewed**: November 2, 2025 -**Next Review**: When adding features that don't require Excel COM (if ever) +**Next Review**: When adding features that don't require PowerPoint COM (if ever) --- @@ -361,7 +361,7 @@ dotnet test --filter "Category=Integration&RunType!=OnDemand&Feature!=VBA&Featur ### Session/Batch Code Changes ```powershell -# MANDATORY when modifying ExcelSession.cs or ExcelBatch.cs +# MANDATORY when modifying PptSession.cs or PptBatch.cs dotnet test --filter "RunType=OnDemand" ``` @@ -372,8 +372,8 @@ dotnet test --filter "(Feature=VBA|Feature=VBATrust)&RunType!=OnDemand" ``` ### CI/CD Pipeline -- **GitHub Actions**: Build verification only (no Excel) -- **Azure Self-Hosted Runner**: All integration tests (Excel installed) +- **GitHub Actions**: Build verification only (no PowerPoint) +- **Azure Self-Hosted Runner**: All integration tests (PowerPoint installed) - **Both must pass** before merge to main --- diff --git a/docs/AUTHOR.md b/docs/AUTHOR.md index bdd0429b..257e64b7 100644 --- a/docs/AUTHOR.md +++ b/docs/AUTHOR.md @@ -3,28 +3,28 @@ ## Stefan Broenner **Email**: [stefan_broenner@yahoo.com](mailto:stefan_broenner@yahoo.com) -**GitHub**: [sbroenne](https://github.com/sbroenne) +**GitHub**: [trsdn](https://github.com/trsdn) ### About the Author -Stefan Broenner is the creator and maintainer of ExcelMcp, a command-line interface tool designed specifically for coding agents and developers to automate Microsoft Excel operations. With a focus on creating clean, reliable tools that bridge the gap between AI coding assistants and Excel automation, Stefan has developed ExcelMcp to be the the solution for programmatic Excel manipulation. +Stefan Broenner is the creator and maintainer of PptMcp, a command-line interface tool designed specifically for coding agents and developers to automate Microsoft PowerPoint operations. With a focus on creating clean, reliable tools that bridge the gap between AI coding assistants and PowerPoint automation, Stefan has developed PptMcp to be the the solution for programmatic PowerPoint manipulation. ### Project Vision -ExcelMcp was born from the need for a simple, reliable way for coding agents like GitHub Copilot to interact with Excel files. Rather than dealing with complex OpenXML libraries or unreliable automation, ExcelMcp provides direct COM automation that uses Excel's native VBA object model for maximum compatibility and reliability. +PptMcp was born from the need for a simple, reliable way for coding agents like GitHub Copilot to interact with PowerPoint files. Rather than dealing with complex OpenXML libraries or unreliable automation, PptMcp provides direct COM automation that uses PowerPoint's native VBA object model for maximum compatibility and reliability. ### Contact -For questions, contributions, or collaboration opportunities related to Sbroenne.ExcelMcp: +For questions, contributions, or collaboration opportunities related to PptMcp: - **Email**: [stefan_broenner@yahoo.com](mailto:stefan_broenner@yahoo.com) -- **GitHub Issues**: [ExcelMcp Issues](https://github.com/sbroenne/mcp-server-excel/issues) -- **GitHub Discussions**: [ExcelMcp Discussions](https://github.com/sbroenne/mcp-server-excel/discussions) +- **GitHub Issues**: [PptMcp Issues](https://github.com/trsdn/mcp-server-ppt/issues) +- **GitHub Discussions**: [PptMcp Discussions](https://github.com/trsdn/mcp-server-ppt/discussions) ### Contributing -Stefan welcomes contributions from the community! Whether you're fixing bugs, adding features, or improving documentation, your contributions help make ExcelMcp better for everyone. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to get involved. +Stefan welcomes contributions from the community! Whether you're fixing bugs, adding features, or improving documentation, your contributions help make PptMcp better for everyone. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to get involved. --- -*Making Excel automation accessible to coding agents and developers worldwide.* +*Making PowerPoint automation accessible to coding agents and developers worldwide.* diff --git a/docs/AZURE_SELFHOSTED_RUNNER_SETUP.md b/docs/AZURE_SELFHOSTED_RUNNER_SETUP.md deleted file mode 100644 index a61f2c1e..00000000 --- a/docs/AZURE_SELFHOSTED_RUNNER_SETUP.md +++ /dev/null @@ -1,863 +0,0 @@ -# Azure Self-Hosted Runner Setup for Excel Integration Testing - -> **⚠️ STATUS: DISABLED** - The Azure self-hosted runner has been undeployed and the integration tests workflow is currently disabled. The workflows `integration-tests.yml` and `deploy-azure-runner.yml` have been renamed to `.disabled` extension. To re-enable, rename them back to `.yml` and redeploy the Azure runner infrastructure. - -> **Purpose:** Enable full Excel COM integration testing in CI/CD using Azure-hosted Windows VM with Microsoft Excel - -## Quick Navigation - -**Choose your path:** - -| Scenario | Guide | Time | -|----------|-------|------| -| **🚀 New setup (no VM)** | [Automated Deployment](#automated-deployment-recommended) | 5 min + 30 min Excel | -| **🔧 Manual setup (existing VM)** | [Manual Installation](#manual-installation) | 15 min + 30 min Excel | -| **📖 Infrastructure details** | [`infrastructure/azure/GITHUB_ACTIONS_DEPLOYMENT.md`](../infrastructure/azure/GITHUB_ACTIONS_DEPLOYMENT.md) | Reference | -| **🔍 Infrastructure code** | [`infrastructure/azure/README.md`](../infrastructure/azure/README.md) | Reference | - ---- - -## Overview - -ExcelMcp requires Microsoft Excel for integration testing. GitHub-hosted runners don't include Excel, so integration tests are currently skipped in CI/CD. This guide shows how to set up an Azure Windows VM with Excel as a GitHub Actions self-hosted runner. - -## Architecture - -``` -┌─────────────────────────────────────────────────────────┐ -│ GitHub Repository │ -│ │ -│ ┌──────────────────────────────────────────┐ │ -│ │ .github/workflows/integration-tests.yml │ │ -│ │ runs-on: [self-hosted, windows, excel] │ │ -│ └────────────────┬─────────────────────────┘ │ -└───────────────────┼──────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────┐ -│ Azure Windows VM │ -│ │ -│ ┌──────────────────────────────────────────┐ │ -│ │ GitHub Actions Runner Service │ │ -│ │ - Windows Server 2022 │ │ -│ │ - .NET 10 SDK │ │ -│ │ - Microsoft Excel (Office 365) │ │ -│ │ - Self-hosted runner agent │ │ -│ └──────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────┘ -``` - ---- - -## Automated Deployment (Recommended) - -**✨ Fastest way to deploy - only manual step is installing Excel!** - -**What gets automated:** -- ✅ VM provisioning (Standard_B2s, 4GB RAM - cheapest suitable option) -- ✅ .NET 10 SDK installation -- ✅ GitHub Actions runner installation & configuration -- ✅ Network security configuration -- ⏭️ **Manual:** Office 365 Excel installation (you must do this via RDP) - -**Complete guide:** [`infrastructure/azure/GITHUB_ACTIONS_DEPLOYMENT.md`](../infrastructure/azure/GITHUB_ACTIONS_DEPLOYMENT.md) - -**Cost:** ~$30/month (with auto-shutdown) or ~$60/month (24/7) in East US region - ---- - -## Manual Installation - -**Use this option if:** -- Automated deployment workflow failed -- You already have a Windows VM -- You want complete control over the setup - -### Prerequisites - -- Windows Server 2022 or Windows 10/11 VM (Azure or on-premises) -- Administrator access to the VM via RDP -- VM has internet connectivity -- Office 365 subscription with Excel license - -### Installation Steps - -#### 1. Connect to VM via RDP - -Get your VM's public IP from Azure Portal, then: -``` -Computer: -Username: Your admin username -Password: Your admin password -``` - -#### 2. Install .NET 10 SDK - -Open PowerShell as Administrator: - -```powershell -# Download .NET 10 SDK -Invoke-WebRequest -Uri "https://aka.ms/dotnet/10.0/dotnet-sdk-win-x64.exe" -OutFile "$env:TEMP\dotnet-sdk.exe" - -# Install silently -Start-Process "$env:TEMP\dotnet-sdk.exe" -ArgumentList '/quiet' -Wait - -# Verify -dotnet --version -``` - -#### 3. Generate GitHub Runner Token - -**Important:** Tokens expire after 1 hour! - -1. Go to repository: `https://github.com/sbroenne/mcp-server-excel` -2. Navigate to **Settings** → **Actions** → **Runners** -3. Click **New self-hosted runner** -4. Select **Windows** -5. Copy the registration token (long alphanumeric string) - -#### 4. Download and Configure GitHub Actions Runner - -In PowerShell as Administrator: - -```powershell -# Create runner directory -New-Item -Path C:\actions-runner -ItemType Directory -Force -Set-Location C:\actions-runner - -# Download latest runner -$runnerVersion = "2.321.0" # Check GitHub for latest version -Invoke-WebRequest -Uri "https://github.com/actions/runner/releases/download/v$runnerVersion/actions-runner-win-x64-$runnerVersion.zip" -OutFile "actions-runner.zip" - -# Extract -Expand-Archive -Path actions-runner.zip -DestinationPath . -Force - -# Configure (replace with your token from Step 3) -$githubToken = "PASTE_YOUR_TOKEN_HERE" -$repoUrl = "https://github.com/sbroenne/mcp-server-excel" - -.\config.cmd --url $repoUrl --token $githubToken --name "azure-excel-runner" --labels "self-hosted,windows,excel" --runnergroup Default --work _work --unattended -``` - -#### 5. Install Runner as Windows Service - -```powershell -# Install service -.\svc.cmd install - -# Start service -.\svc.cmd start - -# Verify -Get-Service actions.runner.* -# Should show: Running -``` - -#### 6. Install Office 365 Excel - -**Manual installation required:** - -1. Open browser on VM → `https://portal.office.com` -2. Sign in with Office 365 account -3. Click **Install Office** → **Office 365 apps** -4. During installation, select **Excel only** -5. Complete installation (~15-30 minutes) -6. Open Excel once to activate (File → Account → verify activation) - -#### 7. Verify Excel COM Access - -```powershell -try { - $excel = New-Object -ComObject Excel.Application - $version = $excel.Version - Write-Host "✅ Excel Version: $version" -ForegroundColor Green - $excel.Quit() - [System.Runtime.InteropServices.Marshal]::ReleaseComObject($excel) | Out-Null -} catch { - Write-Host "❌ Excel not accessible: $_" -ForegroundColor Red -} -``` - -Expected: `✅ Excel Version: 16.0` - -#### 8. Verify Runner Registration - -Check `https://github.com/sbroenne/mcp-server-excel/settings/actions/runners`: -- **Name:** azure-excel-runner -- **Status:** Idle (green circle) -- **Labels:** self-hosted, windows, excel - -#### 9. Test Integration Tests - -1. Go to **Actions** tab → **Integration Tests (Excel)** -2. Click **Run workflow** → select `main` branch -3. Monitor the run - should complete successfully - -### Manual Installation Troubleshooting - -**Runner service won't start:** -```powershell -Get-EventLog -LogName Application -Source actions.runner.* -Newest 20 -``` - -**"Runner already exists" error:** -```powershell -.\config.cmd remove --token YOUR_NEW_TOKEN -# Then reconfigure with Step 4 commands -``` - -**Excel COM test fails:** -- Verify Excel is installed and activated -- Kill background processes: `Get-Process excel | Stop-Process -Force` - -**Runner token expired:** -- Generate new token (Step 3) and reconfigure - ---- - -## Cost Estimate - -## Cost Estimate - -**Monthly costs (East US region - cheapest):** - -| Resource | Specification | Monthly Cost (USD) | -|----------|---------------|-------------------| -| VM (Standard_B2s) | 2 vCPUs, 4 GB RAM | ~$25 | -| Storage (Premium SSD) | 128 GB | ~$5 | -| Network Egress | ~10 GB/month | <$1 | -| **Total (with auto-shutdown)** | | **~$30/month** | - -**Other VM options:** -- Standard_B2ms (2 vCPUs, 8 GB): ~$60/month -- Standard_D2s_v3 (2 vCPUs, 8 GB): ~$70/month - -**Cost optimization:** -- ✅ Use B2s (cheapest suitable VM) -- ✅ Enable auto-shutdown at 7 PM (saves ~50%) -- ✅ Use East US region (cheapest) -- Deallocate when not in use: ~$5/month (storage only) - ---- - -## Azure Portal VM Creation (Optional) - -If you prefer using Azure Portal instead of automation: - -## Azure Portal VM Creation (Optional) - -If you prefer using Azure Portal instead of automation: - -1. Sign in to https://portal.azure.com -2. Create a resource → Virtual Machine -3. Configure: - - Resource Group: `rg-excel-runner` - - VM Name: `vm-excel-runner-01` - - Region: East US (cheapest) - - Image: Windows Server 2022 Datacenter - - Size: Standard_B2s (2 vCPUs, 4 GB RAM) - - Authentication: Set username/password -4. Networking: Allow RDP (3389) -5. Management: Enable auto-shutdown at 7 PM -6. Review + Create - -Then follow [Manual Installation](#manual-installation) steps above. - ---- - -## Maintenance & Operations - -### Monitor Runner Health - -**Check runner status:** -```powershell -# PowerShell on VM -Get-Service actions.runner.* | Format-Table Name, Status, StartType -``` - -**View runner logs:** -```powershell -# On VM -Get-Content "C:\actions-runner\_diag\Runner_*.log" -Tail 50 -``` - -**GitHub Portal:** -- Go to: Settings → Actions → Runners -- Verify runner shows as "Idle" (green) or "Active" (running job) - -### Update Runner - -**When new runner version released:** -```powershell -# Stop service -.\svc.cmd stop - -# Download new version -$newVersion = "2.322.0" # Check GitHub for latest -Invoke-WebRequest -Uri "https://github.com/actions/runner/releases/download/v$newVersion/actions-runner-win-x64-$newVersion.zip" -OutFile "actions-runner-new.zip" - -# Backup old version -Rename-Item "actions-runner.zip" "actions-runner-old.zip" -Rename-Item "actions-runner-new.zip" "actions-runner.zip" - -# Extract (overwrites files) -[System.IO.Compression.ZipFile]::ExtractToDirectory("$PWD\actions-runner.zip", "$PWD") - -# Restart service -.\svc.cmd start -``` - -### Cleanup Excel Processes - -**After failed tests:** -```powershell -# Kill all Excel processes -Get-Process excel -ErrorAction SilentlyContinue | Stop-Process -Force - -# Verify no orphan processes -Get-Process | Where-Object { $_.ProcessName -like "*excel*" -or $_.ProcessName -like "*dotnet*" } -``` - -### Auto-Shutdown Schedule - -**Modify shutdown time:** -```powershell -# Azure Portal -# VM → Auto-shutdown → Change time → Save - -# Azure CLI -az vm auto-shutdown --resource-group rg-excel-runner --name vm-excel-runner-01 --time 1900 # 7 PM -``` - -### Backup Runner Configuration - -**Before major changes:** -```powershell -# Backup runner config -Copy-Item "C:\actions-runner\.runner" "C:\Backup\.runner.bak" -Copy-Item "C:\actions-runner\.credentials" "C:\Backup\.credentials.bak" -``` - ---- - -## Troubleshooting - -### Runner Token Generation Fails - -**Symptoms:** Automated deployment workflow fails with "Failed to generate runner registration token" or "Resource not accessible by integration" (403 error) - -**Root Cause:** The `GITHUB_TOKEN` cannot create runner registration tokens via direct REST API calls, even with `actions: write` permission. This is a GitHub security restriction. - -**Solution:** Use GitHub CLI instead of curl - -**Before (Failed):** -```powershell -curl -L -X POST \ - -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ - https://api.github.com/repos/.../actions/runners/registration-token -``` - -**After (Fixed):** -```powershell -gh api --method POST \ - /repos/${{ github.repository }}/actions/runners/registration-token \ - --jq '.token' -``` - -**Why It Works:** The GitHub CLI (`gh`) has proper authentication mechanisms that work with runner operations, while direct API calls are blocked for security reasons. - -**Verification:** The automated deployment workflow (`.github/workflows/deploy-azure-runner.yml`) already uses this fix. If you're implementing manual deployment, use `gh api` instead of `curl` for token generation. - -### Runner Not Appearing in GitHub - -**Symptoms:** Runner not listed in Settings → Actions → Runners - -**Solutions:** -1. Check service status: `Get-Service actions.runner.*` -2. Restart service: `.\svc.cmd restart` -3. View logs: `Get-Content "C:\actions-runner\_diag\Runner_*.log" -Tail 100` -4. Verify token expiration (tokens expire after 1 hour) - regenerate and reconfigure -5. Check network connectivity: `Test-NetConnection github.com -Port 443` - -### Integration Tests Failing - -**Symptoms:** Tests pass locally but fail on runner - -**Solutions:** - -1. **Excel not activated:** - ```powershell - # Launch Excel manually once - Start-Process excel -Wait - # Sign in with Office 365 account - ``` - -2. **VBA trust not enabled:** - ```powershell - # Set VBA trust registry key - Set-ItemProperty -Path "HKCU:\Software\Microsoft\Office\16.0\Excel\Security" -Name "AccessVBOM" -Value 1 - ``` - -3. **Protected view blocking files:** - ```powershell - # Disable protected view - $pvPath = "HKCU:\Software\Microsoft\Office\16.0\Excel\Security\ProtectedView" - Set-ItemProperty -Path $pvPath -Name "DisableInternetFilesInPV" -Value 1 - ``` - -4. **Excel processes not cleaned up:** - ```powershell - # Add cleanup step to workflow - Get-Process excel -ErrorAction SilentlyContinue | Stop-Process -Force - ``` - -### RDP Connection Issues - -**Cannot connect to VM:** - -1. **Check VM is running:** - ```powershell - az vm get-instance-view --resource-group rg-excel-runner --name vm-excel-runner-01 --query "instanceView.statuses[?starts_with(code, 'PowerState/')].displayStatus" -o tsv - ``` - -2. **Start VM if stopped:** - ```powershell - az vm start --resource-group rg-excel-runner --name vm-excel-runner-01 - ``` - -3. **Verify NSG rules allow your IP:** - ```powershell - az network nsg rule list --resource-group rg-excel-runner --nsg-name vm-excel-runner-01NSG --query "[?name=='RDP'].{Name:name,Priority:priority,SourceAddressPrefix:sourceAddressPrefix}" -o table - ``` - -4. **Update NSG rule to allow your current IP:** - ```powershell - MY_IP=$(curl -s https://api.ipify.org) - az network nsg rule update --resource-group rg-excel-runner --nsg-name vm-excel-runner-01NSG --name RDP --source-address-prefix "$MY_IP/32" - ``` - -### High Azure Costs - -**Monthly bill higher than expected:** - -1. **Check VM running time:** - - Azure Portal → Cost Management → Cost analysis - - Filter by VM resource - -2. **Verify auto-shutdown working:** - ```powershell - az vm show --resource-group rg-excel-runner --name vm-excel-runner-01 --query "autoShutdownConfiguration" - ``` - -3. **Stop VM completely when not needed:** - ```powershell - az vm stop --resource-group rg-excel-runner --name vm-excel-runner-01 - az vm deallocate --resource-group rg-excel-runner --name vm-excel-runner-01 # Important: Deallocate to stop compute billing - ``` - -4. **Downgrade VM size if underutilized:** - ```powershell - # Resize to B2s (cheapest) - az vm resize --resource-group rg-excel-runner --name vm-excel-runner-01 --size Standard_B2s - ``` - -### Excel Automation Errors - -**Tests failing with COM errors:** - -1. **DCOM permissions:** - ```powershell - # Run as Administrator - dcomcnfg - # Component Services → Computers → My Computer → DCOM Config → Microsoft Excel Application - # Right-click → Properties → Identity → The interactive user - ``` - -2. **Excel hanging:** - ```powershell - # Add timeout to test configuration - # In test code: Disable background save, disable add-ins - ``` - -3. **File locks:** - ```powershell - # Ensure tests dispose Excel objects properly - # Check for orphan Excel processes: Get-Process excel - ``` - ---- - -## Cleanup & Decommissioning - -### Remove Runner from GitHub - -**Before deleting VM:** -```powershell -# On VM - stop and remove service -.\svc.cmd stop -.\svc.cmd uninstall - -# Unregister from GitHub -.\config.cmd remove --token YOUR_REMOVAL_TOKEN -``` - -**GitHub Portal:** -- Settings → Actions → Runners -- Click runner name → **Remove runner** - -### Delete Azure Resources - -**Remove all infrastructure:** -```powershell -# Delete resource group (removes VM, disk, network, etc.) -az group delete --name rg-excel-runner --yes --no-wait -``` - -**Verify deletion:** -```powershell -az group list --query "[?name=='rg-excel-runner']" -o table -``` - -### Cost After Deletion - -After deletion: **$0/month** (all resources removed) - -If you only stop VM: **~$5/month** (storage costs remain) - ---- - -## Security Best Practices - -### Minimize Attack Surface - -1. **Restrict RDP access to your IP only** (see [Configure Network Security](#step-4-configure-network-security)) -2. **Use strong admin password** (16+ characters, mixed case, numbers, symbols) -3. **Enable auto-shutdown** to reduce exposure time -4. **Keep Windows updated:** - ```powershell - # Check for updates - Install-Module PSWindowsUpdate -Force - Get-WindowsUpdate - Install-WindowsUpdate -AcceptAll -AutoReboot - ``` - -### GitHub Secrets Management - -- **Never commit runner tokens** to repository -- Use GitHub Secrets for sensitive workflow inputs -- Rotate registration tokens regularly (they expire after 1 hour anyway) - -### Monitor for Suspicious Activity - -**Azure Security Center:** -- Enable Microsoft Defender for Cloud (free tier) -- Review security recommendations -- Monitor alerts for brute-force attempts on RDP - -**GitHub:** -- Review workflow runs for unexpected triggers -- Monitor runner logs for unauthorized jobs - -### Principle of Least Privilege - -- Runner service account should only have permissions needed for tests -- Don't run runner as domain admin or with elevated privileges -- Restrict file system access to runner work directory - ---- - -## Cost Optimization Strategies - -### Strategy 1: Auto-Shutdown (Recommended) - -**Setup:** -- Enable auto-shutdown at 7 PM (or your EOD) -- Manually start VM before running scheduled tests -- Saves ~50% compute costs - -**Best for:** Teams in single time zone, predictable schedules - -### Strategy 2: Start/Stop VM with Automation - -**PowerShell script (local machine):** -```powershell -# start-runner.ps1 -az vm start --resource-group rg-excel-runner --name vm-excel-runner-01 - -# Wait for VM to start -Start-Sleep -Seconds 60 - -# Trigger integration tests (via workflow_dispatch API or push) -# ... - -# stop-runner.ps1 (after tests complete) -az vm stop --resource-group rg-excel-runner --name vm-excel-runner-01 -az vm deallocate --resource-group rg-excel-runner --name vm-excel-runner-01 -``` - -**Best for:** Infrequent test runs, CI/CD pipelines - -### Strategy 3: Use Spot Instances (Advanced) - -**Lower cost but VM can be evicted:** -```powershell -az vm create \ - --resource-group rg-excel-runner \ - --name vm-excel-runner-spot \ - --priority Spot \ - --max-price 0.05 \ - --eviction-policy Deallocate \ - # ... other parameters -``` - -**Best for:** Non-critical test runs, can tolerate interruptions - -### Strategy 4: Resize Based on Load - -**Scale up for heavy workloads:** -```powershell -# Before intensive tests -az vm resize --resource-group rg-excel-runner --name vm-excel-runner-01 --size Standard_D2s_v3 - -# After tests complete -az vm resize --resource-group rg-excel-runner --name vm-excel-runner-01 --size Standard_B2s -``` - -**Best for:** Occasional heavy workloads, cost-sensitive projects - ---- - -## References - -- [GitHub Actions Self-Hosted Runners](https://docs.github.com/en/actions/hosting-your-own-runners/about-self-hosted-runners) -- [Azure Windows VMs Pricing](https://azure.microsoft.com/en-us/pricing/details/virtual-machines/windows/) -- [Excel COM Automation](https://docs.microsoft.com/en-us/office/vba/api/overview/excel) -- [Azure Auto-Shutdown](https://docs.microsoft.com/en-us/azure/virtual-machines/auto-shutdown-vm) - ---- - -## Quick Reference Commands - -**Start/Stop VM:** -```powershell -az vm start --resource-group rg-excel-runner --name vm-excel-runner-01 -az vm stop --resource-group rg-excel-runner --name vm-excel-runner-01 -az vm deallocate --resource-group rg-excel-runner --name vm-excel-runner-01 -``` - -**Check runner status:** -```powershell -Get-Service actions.runner.* -``` - -**Restart runner service:** -```powershell -.\svc.cmd restart -``` - -**View runner logs:** -```powershell -Get-Content "C:\actions-runner\_diag\Runner_*.log" -Tail 50 -``` - -**Kill Excel processes:** -```powershell -Get-Process excel -ErrorAction SilentlyContinue | Stop-Process -Force -``` - -**Check Azure costs:** -```powershell -az consumption usage list --resource-group rg-excel-runner --query "[].{Resource:instanceName,Cost:pretaxCost}" -o table -``` - -### Start/Stop VM - -**Azure Portal:** -- Navigate to VM → Click **Start** or **Stop** - -**Azure CLI:** -```powershell -# Stop VM (deallocate to save costs) -az vm deallocate --resource-group rg-excel-runner --name vm-excel-runner-01 - -# Start VM -az vm start --resource-group rg-excel-runner --name vm-excel-runner-01 -``` - -### Auto-Shutdown Schedule - -**Azure Portal:** -1. Go to VM → **Auto-shutdown** -2. Enable: **On** -3. Shutdown time: `19:00` (7 PM) -4. Time zone: Your local timezone -5. Notification: Configure email (optional) -6. **Save** - -### Update Runner - -**PowerShell (on VM):** -```powershell -# Stop runner service -C:\actions-runner\svc.cmd stop - -# Download latest version -$runnerVersion = "2.321.0" # Update to latest -Invoke-WebRequest -Uri "https://github.com/actions/runner/releases/download/v$runnerVersion/actions-runner-win-x64-$runnerVersion.zip" -OutFile "C:\actions-runner\actions-runner-new.zip" - -# Extract to temp location -Expand-Archive -Path "C:\actions-runner\actions-runner-new.zip" -DestinationPath "C:\actions-runner-new" -Force - -# Replace binaries (preserve config) -Copy-Item -Path "C:\actions-runner-new\*" -Destination "C:\actions-runner\" -Recurse -Force -Exclude ".credentials",".runner" - -# Start runner service -C:\actions-runner\svc.cmd start -``` - -### Monitor Runner Health - -**Check runner status:** -```powershell -# On VM -Get-Service actions.runner.* | Select-Object Name, Status, StartType - -# View logs -Get-Content "C:\actions-runner\_diag\Runner*.log" -Tail 50 -``` - -**GitHub UI:** -- Go to repository **Settings** → **Actions** → **Runners** -- Check runner status (Idle/Active/Offline) - -## Troubleshooting - -### Runner Shows Offline - -**Check service status:** -```powershell -Get-Service actions.runner.* -# If stopped, restart: -Restart-Service actions.runner.* -``` - -**Check network connectivity:** -```powershell -Test-NetConnection -ComputerName github.com -Port 443 -``` - -### Excel COM Errors in Tests - -**Verify Excel is installed:** -```powershell -Get-ItemProperty HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\* | Where-Object { $_.DisplayName -like "*Excel*" } -``` - -**Check Excel process cleanup:** -```powershell -# Kill orphaned Excel processes -Get-Process excel -ErrorAction SilentlyContinue | Stop-Process -Force -``` - -### Tests Timeout - -- Increase `timeout-minutes` in workflow -- Check VM performance (CPU/RAM usage) -- Consider upgrading VM size - -### Licensing Issues - -- Ensure Office 365 license is active -- Re-activate Excel if needed: - ```powershell - Start-Process excel - # Sign in interactively via RDP - ``` - -## Security Best Practices - -1. **Restrict Runner to Private Repos Only** - - Go to **Settings** → **Actions** → **Runner groups** - - Ensure runner group only allows private repositories - -2. **Use Dedicated Service Account** - - Create Azure AD user specifically for runner - - Apply principle of least privilege - -3. **Regular Updates** - - Enable Windows Update - - Update runner agent monthly - - Update Excel/Office monthly - -4. **Secrets Management** - - Never hardcode credentials in workflows - - Use GitHub Secrets for sensitive data - - Rotate runner registration tokens - -5. **Network Isolation** - - Use Azure Bastion instead of RDP (enterprise) - - Restrict NSG to minimum required ports - - Consider private VNet for runner - -## Alternative Solutions - -### Option 1: Azure Container Apps (Future) - -Microsoft is developing container-based CI/CD runners that could potentially support Windows containers with Excel. Monitor [this announcement](https://learn.microsoft.com/en-us/azure/container-apps/tutorial-ci-cd-runners-jobs). - -### Option 2: Azure Virtual Desktop Multi-Session - -For multiple concurrent test runs, consider Azure Virtual Desktop with multi-session host pools. - -### Option 3: Third-Party Hosted Runners - -Some CI/CD providers offer Windows runners with Office pre-installed: -- **BuildJet** (GitHub Actions accelerator with custom images) -- **Cirrus CI** (Windows containers with Office) - -Cost comparison needed before adoption. - -## Cost Optimization Strategies - -1. **Scheduled Start/Stop** - - Use Azure Automation runbooks - - Start VM 30 min before scheduled test run - - Stop VM after tests complete - -2. **Spot VMs** - - Save up to 90% on VM costs - - Acceptable for non-critical test runs - - Risk: VM can be evicted by Azure - -3. **Reserved Instances** - - 1-year commitment: ~40% savings - - 3-year commitment: ~60% savings - - Only if runner runs 24/7 - -4. **B-Series Burstable VMs** - - Lower base cost - - Suitable for intermittent workloads - - May impact test performance - -## Next Steps - -After setup: - -1. ✅ Test runner with simple workflow -2. ✅ Run integration tests manually -3. ✅ Configure auto-shutdown to reduce costs -4. ✅ Set up monitoring/alerting -5. ✅ Document runner in team wiki - -## Additional Resources - -- [GitHub Self-Hosted Runners Documentation](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners) -- [Azure Virtual Machines Documentation](https://learn.microsoft.com/en-us/azure/virtual-machines/) -- [Office Deployment Tool](https://learn.microsoft.com/en-us/deployoffice/overview-office-deployment-tool) -- [Azure Cost Management](https://azure.microsoft.com/en-us/products/cost-management/) - -## Support - -For issues or questions: -- GitHub Issues: https://github.com/sbroenne/mcp-server-excel/issues -- Documentation: [DEVELOPMENT.md](DEVELOPMENT.md) diff --git a/docs/BREAKING-CHANGES.md b/docs/BREAKING-CHANGES.md deleted file mode 100644 index d7df26f3..00000000 --- a/docs/BREAKING-CHANGES.md +++ /dev/null @@ -1,99 +0,0 @@ -# Breaking Changes - -> **Version:** 1.7.0 (MCP-Daemon Unification) -> **PR:** [#433](https://github.com/sbroenne/mcp-server-excel/pull/433) -> **Date:** February 2026 - -**📌 Note for AI Assistants:** LLMs will automatically discover these changes via `tools/list` (MCP) and `--help` (CLI). This document is informational for human developers. - -**Full technical details:** [API-COMPARISON-REPORT.md](archive/API-COMPARISON-REPORT.md) - ---- - -## MCP Server Changes - -### 1. `excelPath` Parameter Removed (11 Tools) - -**Removed from:** `calculation_mode`, `conditionalformat`, `connection`, `namedrange`, `range`, `range_edit`, `range_format`, `range_link`, `table`, `table_column`, `vba` - -**Why:** Daemon architecture — session already knows the file context. Only `sessionId` required. - ---- - -### 2. `file` Parameter Renames - -- `excelPath` → `path` -- `showExcel` → `show` - ---- - -### 3. `connection` (-4 params) - -**Removed:** `newCommandText`, `newConnectionString`, `newDescription` - -**Why:** `set-properties` reuses existing params instead of separate `new*` versions. - ---- - -### 4. `datamodel` (+4 params, 2 renames) - -**Added:** `daxFormulaFile`, `daxQueryFile`, `dmvQueryFile`, `timeout` - -**Renamed:** `formatString` → `formatType`, `newTableName` → `newName` - ---- - -### 5. `datamodel_relationship` (5 action renames + 5 param renames) - -**Actions renamed:** -- `list` → `list-relationships` -- `read` → `read-relationship` -- `create` → `create-relationship` -- `update` → `update-relationship` -- `delete` → `delete-relationship` - -**Parameters shortened:** `fromTableName` → `fromTable`, `toTableName` → `toTable`, `fromColumnName` → `fromColumn`, `toColumnName` → `toColumn`, `isActive` → `active` - ---- - -## CLI Changes - -### 1. Action Rename - -`table add-to-datamodel` → `table add-to-data-model` - ---- - -### 2. Parameter Renames (9 Commands) - -Short → descriptive naming in: `calculationmode`, `conditionalformat`, `connection`, `datamodel`, `namedrange`, `powerquery`, `vba` - -Examples: `--sheet` → `--sheet-name`, `--mcode` → `--m-code`, `--expression` → `--dax-formula` - ---- - -### 3. `pivottable` Command (+23 Actions) - -Merged actions from `pivottablefield` and `pivottablecalc` into single command. All original 7 actions preserved. - ---- - -## Summary - -- **MCP:** 297 → 287 parameters (-10) -- **CLI:** Parameter renames in 9 commands, 1 action rename, 23 new pivottable actions -- **Architecture:** Unified daemon service for both MCP and CLI - ---- - -## For Human Developers - -**Update hardcoded scripts:** -1. Remove `excelPath` from 11 session-based MCP tools -2. Update `file`, `connection`, `datamodel`, `datamodel_relationship` parameter names -3. Update CLI parameter names (use `excelcli --help` to see current names) -4. Rename `add-to-datamodel` → `add-to-data-model` in table commands - -**For AI Assistants:** -- Query tools dynamically — no hardcoded parameter names needed -- Use `tools/list` (MCP) or `--help` (CLI) to discover current schemas diff --git a/docs/CLAUDE-MCPB-SUBMISSION.md b/docs/CLAUDE-MCPB-SUBMISSION.md deleted file mode 100644 index 05bc940c..00000000 --- a/docs/CLAUDE-MCPB-SUBMISSION.md +++ /dev/null @@ -1,41 +0,0 @@ -# Claude MCPB Submission Guide - -## Purpose -Submit Excel MCP Server to Anthropic’s Claude Directory as an MCPB bundle for one-click installation in Claude Desktop. - -## Prerequisites -- MCPB bundle built and validated -- 512×512 PNG icon available -- Privacy page published - -## Required Assets -- MCPB bundle: GitHub Actions release workflow artifact (.mcpb) -- MCPB manifest: mcpb/manifest.json -- Icon: mcpb/icon-512.png -- Privacy page: https://excelmcpserver.dev/privacy/ - -## Build Steps -1. Run the release workflow to produce the MCPB artifact. -2. Download the MCPB artifact from the workflow run. -3. Verify the artifact is the intended .mcpb bundle for submission. - -## Tool Annotation Requirement -The C# MCP SDK maps tool hints from [McpServerTool] attribute properties: -- Destructive = true → annotations.destructiveHint = true -- ReadOnly, Idempotent, OpenWorld map similarly - -All 22 tools in this repository set Destructive = true. - -## Submission Form Checklist -Fill the Claude Directory submission form with: -- Server name: Excel MCP Server -- MCPB file: downloaded workflow artifact (.mcpb) -- Website: https://excelmcpserver.dev/ -- Privacy policy: https://excelmcpserver.dev/privacy/ -- Support or repo link: https://github.com/sbroenne/mcp-server-excel -- Icon: mcpb/icon-512.png -- Platform notes: Windows-only (Excel COM), x64 self-contained build - -## Post-Submission -- Record submission timestamp and form confirmation URL in the GitHub issue -- If requested, attach the MCPB bundle and icon to the issue diff --git a/docs/COM-API-BEHAVIOR-FINDINGS.md b/docs/COM-API-BEHAVIOR-FINDINGS.md deleted file mode 100644 index c5856618..00000000 --- a/docs/COM-API-BEHAVIOR-FINDINGS.md +++ /dev/null @@ -1,506 +0,0 @@ -# Excel COM API Behavior Findings - -> **Summary of diagnostic test findings from empirical testing of raw Excel COM API behavior** - -This document captures the key discoveries made through diagnostic tests that use raw COM API calls to understand Excel's actual behavior, without our abstractions. - -## Test Suites - -- **PowerQueryComApiBehaviorTests**: 12 scenarios testing Power Query M code operations -- **DataModelComApiBehaviorTests**: 11 scenarios testing Data Model (Power Pivot) operations - -## Critical Finding: Query Deletion Orphans Tables - -### Power Query (Scenario 4) - -**FINDING: Deleting a Power Query does NOT delete the associated table (ListObject)** - -```csharp -// Delete the query -query.Delete(); - -// Table SURVIVES - query deletion does NOT cascade to table -// Tables after delete: 1 (same as before) -// Orphaned table name: TestQuery -// Data rows still accessible: 3 -``` - -**Implication:** Cleanup code that removes orphaned tables after query deletion is **JUSTIFIED** and necessary. This is not "cargo cult" code - it addresses real orphan behavior. - -### Data Model (Scenario 8) - -**FINDING: Deleting a Power Query does NOT delete the associated Data Model table** - -```csharp -// Delete query that loaded to Data Model -query.Delete(); - -// Model table SURVIVES! -// Queries after delete: 0 -// Model tables after delete: 1 (orphaned) -``` - -**Implication:** Same as Power Query - Data Model tables become orphaned when their source query is deleted. Cleanup code is required. - ---- - -## Power Query API Findings - -### Scenario 1: Query Creation and Loading - -- `Queries.Add(name, formula)` creates query definition only -- Query alone does NOT create a table -- To load to worksheet, must use `QueryTables.Add()` with connection string: - - ``` - OLEDB;Provider=Microsoft.Mashup.OleDb.1;Data Source=$Workbook$;Location={QueryName} - ``` - -- `QueryTable.Refresh(false)` required for synchronous data load - -### Scenario 2-3: Formula Updates - -**FINDING: Updating formula does NOT automatically update loaded data** - -```csharp -query.Formula = ModifiedQuery; // Updates M code -// Table still shows OLD data -// Must call qt.Refresh(false) to update -``` - -**Implication:** Refresh operation is required after formula updates. - -### Scenario 6: Connection-Only Mode - -**FINDING: There is NO "connection-only" flag on WorkbookQuery** - -- Excel UI has this option, but COM API does not expose it -- `WorkbookQuery` only has these members: - - **Properties:** `Application`, `Creator`, `Description`, `Formula`, `Name`, `Parent` - - **Methods:** `Delete()`, `Refresh()` - - **NO `IsConnectionOnly`, `LoadDestination`, or similar property exists** -- "Connection-only" is the **ABSENCE** of load destinations (no ListObject, no Data Model connection) -- To convert loaded query to connection-only: - 1. Find and remove worksheet tables (ListObjects with matching QueryTable) - 2. Find and remove Data Model connections (connections with "Query - {name}" pattern) - 3. Keep the query in `Workbook.Queries` collection - -**Implication:** Connection-only must be implemented by removing ALL load destinations. - -### Scenario 12-14: Unload to Connection-Only (Bug Discovery) - -**CRITICAL BUG FOUND: Unload only removes worksheet tables, NOT Data Model connections** - -```csharp -// Current Unload implementation (INCOMPLETE): -foreach (ListObject in worksheet.ListObjects) -{ - if (QueryTable.Connection.Contains(queryName)) - listObject.Unlist(); -} -// BUG: Never checks/removes Data Model connections! -``` - -**Scenarios Tested:** - -| Scenario | Initial State | After Current Unload | Correct Result | -|----------|--------------|---------------------|----------------| -| 12: Data Model Only | Query → Data Model | Data Model connection REMAINS | Should remove connection | -| 13: Both Destinations | Query → Worksheet + Data Model | Worksheet removed, Data Model REMAINS | Should remove both | -| 14: Proper Implementation | Query → Both | BOTH removed | ✅ Correct | - -**Proper Connection-Only Implementation:** - -```csharp -// Step 1: Remove worksheet tables (existing behavior) -foreach (ListObject in worksheet.ListObjects) -{ - if (QueryTable.Connection.Contains(queryName)) - listObject.Unlist(); -} - -// Step 2: Remove Data Model connections (MISSING!) -foreach (Connection in workbook.Connections) -{ - if (connection.Name == $"Query - {queryName}") - connection.Delete(); -} - -// Query remains in Workbook.Queries = connection-only -``` - -**Implication:** `Unload` method in `PowerQueryCommands.Lifecycle.cs` needs to also remove Data Model connections. - ---- - -### Scenario 15: Data Model State Blocks Formula Updates (UNRESOLVED) - -**FINDING: WorkbookQuery.Formula becomes read-only in certain workbook states** - -**Error Code:** `0x800A03EC` (-2146827284) - "Application-defined or object-defined error" - -**Symptoms:** -- `query.Formula = mCode` fails with 0x800A03EC on some workbooks -- Error affects ALL queries in the workbook, not just Data Model queries -- Error is workbook-specific - same queries work fine in fresh workbooks - -**Attempted Solutions (Did NOT Work):** -1. **Save-and-retry**: Saving workbook before retry did NOT clear the error state -2. **Polly retry with backoff**: Error is NOT transient - retries don't help -3. **File location**: Copying file from OneDrive to local drive did NOT help - -**Current Status:** Root cause unknown. The error appears to be related to internal Excel state that cannot be cleared programmatically via COM automation. Workaround: manually save and reopen the workbook in Excel UI. - -**See:** GitHub Issue #323 - ---- - -### Scenario 16: Query Renaming - -**FINDING: Renaming a query breaks the connection to the table** - -```csharp -query.Name = "RenamedQuery"; -// Table's QueryTable.Connection still references old name! -// Refresh FAILS because connection can't find "OldName" -``` - -**Implication:** Renaming requires fixing the connection string or recreating the table. - ---- - -## Data Model API Findings - -### ModelMeasures.Add() Signature - -**CORRECT Signature:** - -```csharp -measures.Add( - "MeasureName", // Required: String - modelTable, // Required: ModelTable object (NOT string!) - "DAX Formula", // Required: String - model.ModelFormatGeneral // Required: ModelFormat* object (NOT null!) - // Description // Optional: String -); -``` - -**Key Points:** - -- `AssociatedTable` parameter is a **ModelTable object**, not a table name string -- `FormatInformation` is **REQUIRED** - use `model.ModelFormatGeneral` for default format -- Available format types: ModelFormatGeneral, ModelFormatCurrency, ModelFormatDate, ModelFormatDecimalNumber, ModelFormatWholeNumber, ModelFormatPercentageNumber, ModelFormatScientificNumber, ModelFormatBoolean - -### Scenario 3-5: Measure Lifecycle - -- Measures can be added, updated (by changing Formula property), and deleted -- `measure.Delete()` removes the measure from the model -- Measure can reference any table in the model, not just the associated table - -### Scenario 6-7: Relationships - -- Relationships require two tables with compatible columns -- `ModelRelationships.Add()` creates relationship between columns -- Relationships can be deleted individually - -### Scenario 9: Orphaned Measures - -**FINDING: Measures referencing deleted tables become invalid** - -When a table is removed from the Data Model, measures that reference it remain but become #ERROR. - ---- - -## COM API Quirks - -### 1-Based Indexing - -All Excel COM collections use 1-based indexing: - -```csharp -collection.Item(1) // First item, NOT collection.Item(0) -``` - -### Numeric Property Types - -All Excel COM numeric properties return `double`, not `int`: - -```csharp -// WRONG: Runtime error -int position = field.Position; - -// CORRECT: Explicit conversion required -int position = Convert.ToInt32(field.Position); -``` - -### Error Handling - -COM exceptions provide HResult codes for error identification: - -```csharp -catch (COMException ex) when (ex.HResult == -2147417851) // RPC_E_SERVERCALL_RETRYLATER -``` - ---- - -## Implications for Production Code - -### 1. Cleanup Code is Necessary - -The discovery that `query.Delete()` leaves tables orphaned validates our cleanup code: - -- Power Query delete should also delete/unlist associated table -- Data Model cleanup should remove orphaned model tables - -### 2. Refresh is Required After Updates - -Formula changes do NOT auto-refresh: - -- After updating M code, must call refresh -- Refresh should be synchronous (`Refresh(false)`) - -### 3. Connection Strings Must Be Managed - -When renaming queries, connection strings break: - -- Must update connection string or recreate table -- Consider using query name as table name for consistency - -### 4. ModelMeasures.Add Requires Proper Parameters - -The API signature differs from what might be assumed: - -- TableName → ModelTable object -- FormatInformation → Required ModelFormat* object (not null) - -### 5. Unload Must Remove ALL Load Destinations (BUG FIX NEEDED) - -Current `Unload` method only removes worksheet tables (ListObjects): - -- **Missing:** Removal of Data Model connections -- Queries loaded only to Data Model are NOT affected by current Unload -- Queries loaded to BOTH destinations only have worksheet table removed - -**Fix Required in `PowerQueryCommands.Lifecycle.cs`:** - -```csharp -// After unlisting worksheet tables, also remove Data Model connections: -foreach (Connection in workbook.Connections) -{ - if (connection.Name == $"Query - {queryName}") - connection.Delete(); -} -``` - -### 6. CUBEVALUE Formula Limitation in Automation Mode (Issue #313) - -**CRITICAL FINDING: CUBEVALUE formulas do NOT work in Excel COM automation mode** - -**Symptoms:** -- CUBEVALUE returns #N/A when Excel is hidden (automation mode) -- CUBEVALUE returns #VALUE! when Excel is visible -- All syntax variations fail, including: - - `=CUBEVALUE("ThisWorkbookDataModel","[Measures].[TotalAmount]")` - - `=CUBEVALUE("ThisWorkbookDataModel","Query[TotalAmount]")` - - `=CUBEVALUE("ThisWorkbookDataModel","[Query].[Measures].[TotalAmount]")` - -**What Works (via COM):** -- `Workbook.Model` - full access to Data Model object -- `Model.ModelTables` - listing/accessing tables -- `Model.ModelMeasures.Add()` - creating DAX measures -- `Model.ModelRelationships` - creating/managing relationships -- `Model.Refresh()` - refreshing the model -- `Model.DataModelConnection` - returns "ThisWorkbookDataModel" (Type 7 connection) -- `Model.CreateModelWorkbookConnection()` - creates additional connections -- Power Query loading to Data Model via `CreateModelConnection=true` - -**What Fails (via COM):** -- **CUBEVALUE worksheet function** - cannot resolve measures even with correct connection name -- **CUBEMEMBER worksheet function** - also fails with #N/A -- Calculate methods succeed but don't resolve CUBEVALUE -- Error codes: - - -2146826245 = #N/A (member doesn't exist in cube or syntax incorrect) - - -2146826246 = #VALUE! (invalid tuple element) - - 0x800AC472 = Excel busy (Calculate blocked in hidden mode) - -**Root Cause (Confirmed by Microsoft Documentation):** -Per Microsoft's [Client Architecture Requirements for Analysis Services Development](https://learn.microsoft.com/en-us/analysis-services/multidimensional-models/olap-physical/client-architecture-requirements-for-analysis-services-development): - -> **"Power Pivot for Excel and SQL Server Data Tools are the only client environments that are supported for creating and querying in-memory databases that use SharePoint or Tabular mode."** - -The embedded VertiPaq/Analysis Services engine uses **INPROC transport** for in-process communication only. COM automation (external process) is NOT a supported client environment for querying the Data Model via CUBE functions. The Model object and its collections (ModelTables, ModelMeasures, ModelRelationships) work because they use Excel's internal object model, not the OLAP query layer. - -**Workaround - Use PivotTables:** -To evaluate DAX measures programmatically, use PivotTable-based approaches: -1. Create a PivotTable connected to the Data Model (`pivottable action: 'CreateFromDataModel'`) -2. Add the measure as a value field (`pivottable action: 'AddValueField'`) -3. Read the PivotTable values (`pivottable action: 'GetData'`) - -**Microsoft Docs References:** -- CUBEVALUE returns #N/A if "member doesn't exist in the cube" -- CUBEVALUE returns #VALUE! if "at least one element within the tuple is invalid" -- [CUBEVALUE function documentation](https://learn.microsoft.com/en-us/office/client-developer/excel/cubevalue-function) - -**Test Evidence:** Scenario12 and Scenario13 in `DataModelComApiBehaviorTests.cs` - ---- - -### Scenario 14-15: DAX EVALUATE Query Execution (Issue #356) - -**CRITICAL FINDING: DAX EVALUATE queries CAN be executed via COM automation** - -Despite CUBEVALUE/CUBEMEMBER worksheet functions failing (see above), DAX EVALUATE queries **DO WORK** through alternative COM APIs: - -**What FAILED (Scenario 14):** -- `ListObjects.Add(xlSrcModel, ...)` - Returns "Value does not fall within expected range" -- `Connections.Add2(..., xlCmdDAX, ...)` - Same error - -**What WORKS (Scenario 15):** - -1. **Model.CreateModelWorkbookConnection + xlCmdDAX:** - ```csharp - // Create a model connection for a table - dynamic modelWbConn = model.CreateModelWorkbookConnection("TableName"); - dynamic modelConnection = modelWbConn.ModelConnection; - - // Change command type to xlCmdDAX (8) - modelConnection.CommandType = 8; // xlCmdDAX - modelConnection.CommandText = "EVALUATE 'TableName'"; - - // Refresh executes the DAX query - modelWbConn.Refresh(); // ✅ SUCCESS! - ``` - -2. **ModelConnection.ADOConnection.Execute (BEST APPROACH):** - ```csharp - // Get DataModelConnection and its ModelConnection - dynamic dataModelConn = model.DataModelConnection; - dynamic modelConn = dataModelConn.ModelConnection; - - // Get ADO connection - this is a live MSOLAP connection! - dynamic adoConnection = modelConn.ADOConnection; - // ConnectionString: Provider=MSOLAP.8;...Data Source=$Embedded$... - - // Execute DAX EVALUATE query directly - dynamic recordset = adoConnection.Execute("EVALUATE 'TableName'"); - - // Read results from recordset - while (!recordset.EOF) - { - // recordset.Fields.Item(0).Value, etc. - recordset.MoveNext(); - } - ``` - -**ADOConnection Details:** -- Provider: `MSOLAP.8` (Analysis Services OLE DB Provider) -- Data Source: `$Embedded$` (in-process connection to Excel's Data Model) -- Returns: Standard ADO Recordset with DAX query results -- Fields include fully qualified column names: `TableName[ColumnName]` - -**This is DIFFERENT from CUBEVALUE:** -- CUBEVALUE uses the worksheet function evaluation layer → blocked by INPROC transport limitation -- ADOConnection.Execute uses the MSOLAP provider directly → works! - -**Implication for Issue #356:** -An `evaluate` action can be added to `datamodel` tool using the ADOConnection approach: -1. Get `Workbook.Model.DataModelConnection.ModelConnection.ADOConnection` -2. Execute DAX EVALUATE query via `adoConnection.Execute(daxQuery)` -3. Convert ADO Recordset to JSON result - -**Test Evidence:** Scenario14 and Scenario15 in `DataModelComApiBehaviorTests.cs` - ---- - -### DAX-Backed Excel Tables (Scenario 16) - -**FINDING: Excel Tables (ListObjects) can be backed by DAX queries!** - -This extends the Scenario 15 discovery - not only can DAX queries be executed, but the results can be materialized as Excel Tables that automatically update when the Data Model changes. - -**What WORKS (Scenario 16):** - -```csharp -// 1. Create a model workbook connection for a table -dynamic modelWbConn = model.CreateModelWorkbookConnection("TableName"); -dynamic modelConnection = modelWbConn.ModelConnection; - -// 2. Configure for DAX EVALUATE query -modelConnection.CommandType = 8; // xlCmdDAX -modelConnection.CommandText = @" - EVALUATE - SUMMARIZECOLUMNS( - 'Query'[Region], - ""TotalAmount"", SUM('Query'[Amount]), - ""TotalQty"", SUM('Query'[Qty]) - )"; - -// 3. Refresh to execute the DAX query -modelWbConn.Refresh(); - -// 4. Create Excel Table (ListObject) backed by the DAX query! -dynamic listObjects = targetSheet.ListObjects; -dynamic listObject = listObjects.Add( - 4, // xlSrcModel = 4 (PowerPivot Model source) - modelWbConn, // The ModelWorkbookConnection with DAX - true, // HasHeaders - 1, // xlYes = 1 - destRange // Target range -); - -// 5. Refresh the table to populate data -listObject.Refresh(); - -// Result: Excel Table with DAX-aggregated data! -// Headers: Region, TotalAmount, TotalQty -// Data: Aggregated rows from the DAX SUMMARIZECOLUMNS query -``` - -**Key Constants:** -- `xlSrcModel = 4` - ListObject source type for PowerPivot/Data Model -- `xlCmdDAX = 8` - Command type for DAX queries - -**Capabilities Unlocked:** -1. **DAX Aggregation Tables** - Create summary tables with SUMMARIZE, SUMMARIZECOLUMNS, TOPN, etc. -2. **Filtered Data Tables** - Use FILTER, CALCULATETABLE to create subsets -3. **Cross-Table Analysis** - Join/aggregate data across multiple Data Model tables -4. **Auto-Refreshable** - Tables update when underlying Data Model refreshes - -**Potential New Features for Issue #356:** -- Add `create-table-from-dax` action to `table` tool -- Allow users to create Excel Tables populated by arbitrary DAX EVALUATE queries -- Tables stay linked to Data Model and can be refreshed - -**Test Evidence:** Scenario16 in `DataModelComApiBehaviorTests.cs` - ---- - -## Test Execution - -Run diagnostic tests on demand: - -```powershell -# Power Query diagnostics -dotnet test --filter "FullyQualifiedName~PowerQueryComApiBehaviorTests" - -# Data Model diagnostics -dotnet test --filter "FullyQualifiedName~DataModelComApiBehaviorTests" -``` - -These tests are marked `[Trait("RunType", "OnDemand")]` and excluded from regular test runs due to their longer execution time. - ---- - -## References - -- Microsoft VBA Documentation: -- NetOffice (C# COM wrappers): -- Test Files (in `tests/ExcelMcp.Diagnostics.Tests/`): - - `Integration/Diagnostics/PowerQueryComApiBehaviorTests.cs` - - `Integration/Diagnostics/DataModelComApiBehaviorTests.cs` - - `Integration/Diagnostics/PivotTableRefreshBehaviorTests.cs` - -**NOTE: Diagnostics tests are excluded from CI. Run manually with:** -```powershell -dotnet test tests/ExcelMcp.Diagnostics.Tests/ --filter "RunType=OnDemand&Layer=Diagnostics" -``` diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index fd82f32a..1519edc7 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,10 +1,10 @@ -# Contributing to ExcelMcp +# Contributing to PptMcp -Thank you for your interest in contributing to Sbroenne.ExcelMcp! This project is designed to be extended by the community, especially to support coding agents like GitHub Copilot. +Thank you for your interest in contributing to PptMcp! This project is designed to be extended by the community, especially to support coding agents like GitHub Copilot. ## 🎯 Project Vision -ExcelMcp aims to be the go-to command-line tool for coding agents to interact with Microsoft Excel files. We prioritize: +PptMcp aims to be the go-to command-line tool for coding agents to interact with Microsoft PowerPoint files. We prioritize: - **Simplicity** - Clear, predictable commands - **Reliability** - Robust COM automation @@ -16,15 +16,15 @@ ExcelMcp aims to be the go-to command-line tool for coding agents to interact wi ### Development Environment 1. **Prerequisites**: - - Windows OS (required for Excel COM) + - Windows OS (required for PowerPoint COM) - Visual Studio 2022 or VS Code - .NET 10 SDK - - Microsoft Excel installed + - Microsoft PowerPoint installed 2. **Setup**: ```powershell - git clone https://github.com/sbroenne/mcp-server-excel.git - cd ExcelMcp + git clone https://github.com/trsdn/mcp-server-ppt.git + cd PptMcp dotnet restore dotnet build ``` @@ -46,7 +46,7 @@ ExcelMcp aims to be the go-to command-line tool for coding agents to interact wi 3. **Test Your Setup**: ```powershell - dotnet run -- pq-list "path/to/test.xlsx" + dotnet run -- pq-list "path/to/test.pptx" ``` ## 📋 Development Guidelines @@ -80,13 +80,13 @@ public class MyCommands : IMyCommands if (!ValidateArgs(args, expectedCount, "usage string")) return 1; - // Excel automation using batch API + // PowerPoint automation using batch API var task = Task.Run(async () => { - await using var batch = await ExcelSession.BeginBatchAsync(filePath); + await using var batch = await PptSession.BeginBatchAsync(filePath); return batch.Execute((ctx, ct) => { - // Use ctx.Book for workbook access + // Use ctx.Presentation for presentation access // Your implementation return 0; // Success }); @@ -98,15 +98,15 @@ public class MyCommands : IMyCommands #### Critical Rules -1. **Always use batch API** - Never manage Excel lifecycle manually -2. **Excel uses 1-based indexing** - `collection.Item(1)` is the first element +1. **Always use batch API** - Never manage PowerPoint lifecycle manually +2. **PowerPoint uses 1-based indexing** - `collection.Item(1)` is the first element 3. **Use `QueryTables.Add()` not `ListObjects.Add()`** - For loading Power Query data 4. **Escape user input** - Always use `.EscapeMarkup()` with Spectre.Console 5. **Return 0 for success, 1+ for errors** - Consistent exit codes -### Excel COM Best Practices +### PowerPoint COM Best Practices -- **Late binding with dynamic types** - Use `Type.GetTypeFromProgID("Excel.Application")` +- **Late binding with dynamic types** - Use `Type.GetTypeFromProgID("PowerPoint.Application")` - **Proper error handling** - Catch `COMException` and provide helpful messages - **Resource cleanup** - Batch API handles COM object lifecycle automatically - **Input validation** - Check file existence and argument counts early @@ -115,11 +115,11 @@ public class MyCommands : IMyCommands Before submitting: -1. **Manual testing** with various Excel files -2. **Verify Excel process cleanup** - No `excel.exe` should remain after 5 seconds +1. **Manual testing** with various PowerPoint files +2. **Verify PowerPoint process cleanup** - No `powerpnt.exe` should remain after 5 seconds 3. **Test error conditions** - Missing files, invalid arguments, etc. 4. **VBA script testing** - For script-related commands, test with real VBA macros -5. **Cross-version compatibility** - Test with different Excel versions if possible +5. **Cross-version compatibility** - Test with different PowerPoint versions if possible ## 🔧 Adding New Commands @@ -127,7 +127,7 @@ Before submitting: ```csharp // Commands/INewCommands.cs -namespace ExcelMcp.Commands; +namespace PptMcp.Commands; public interface INewCommands { @@ -141,7 +141,7 @@ public interface INewCommands // Commands/NewCommands.cs using Spectre.Console; -namespace ExcelMcp.Commands; +namespace PptMcp.Commands; public class NewCommands : INewCommands { @@ -175,10 +175,10 @@ Add your command to the help output in `ShowHelp()`. - [ ] Code builds with zero warnings - [ ] All existing commands still work -- [ ] Excel processes clean up properly +- [ ] PowerPoint processes clean up properly - [ ] Added appropriate error handling - [ ] Updated help text if needed -- [ ] Tested with various Excel files +- [ ] Tested with various PowerPoint files ### PR Description Template @@ -193,8 +193,8 @@ Brief description of changes - [ ] Documentation update ## Testing -- [ ] Tested manually with Excel files -- [ ] Verified Excel process cleanup +- [ ] Tested manually with PowerPoint files +- [ ] Verified PowerPoint process cleanup - [ ] Tested error conditions - [ ] VBA script execution tested (if applicable) - [ ] No build warnings @@ -237,10 +237,10 @@ AnsiConsole.MarkupLine($"[cyan]{title}[/]"); When reporting bugs, please include: -- **Excel version** and Windows version +- **PowerPoint version** and Windows version - **Command used** and arguments - **Expected behavior** vs actual behavior -- **Sample Excel file** (if possible) +- **Sample PowerPoint file** (if possible) - **Error messages** (full text) ## 💡 Feature Requests @@ -249,12 +249,12 @@ Great feature requests include: - **Use case description** - Why is this needed? - **Proposed command syntax** - How should it work? -- **Excel operations involved** - What APIs would be used? +- **PowerPoint operations involved** - What APIs would be used? - **Target users** - Coding agents? Direct users? ## 📚 Learning Resources -- [Excel VBA Object Model Reference](https://docs.microsoft.com/en-us/office/vba/api/overview/excel) +- [PowerPoint VBA Object Model Reference](https://docs.microsoft.com/en-us/office/vba/api/overview/powerpoint) - [Power Query M Language Reference](https://docs.microsoft.com/en-us/powerquery-m/) - [Spectre.Console Documentation](https://spectreconsole.net/) - [.NET COM Interop Guide](https://docs.microsoft.com/en-us/dotnet/framework/interop/interoperating-with-unmanaged-code) @@ -270,10 +270,10 @@ Great feature requests include: - `documentation` - Documentation improvements - `good first issue` - Good for newcomers - `help wanted` - Extra attention needed -- `excel-com` - Excel COM automation issues +- `ppt-com` - PowerPoint COM automation issues - `power-query` - Power Query specific - `coding-agent` - Coding agent related --- -Thank you for contributing to Sbroenne.ExcelMcp! Together we're making Excel automation more accessible to coding agents and developers worldwide. 🚀 +Thank you for contributing to PptMcp! Together we're making PowerPoint automation more accessible to coding agents and developers worldwide. 🚀 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 1de901e3..0697fe2d 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -48,7 +48,7 @@ git push origin feature/your-feature-name ### 4. **Create Pull Request** -1. Go to [GitHub Repository](https://github.com/sbroenne/mcp-server-excel) +1. Go to [GitHub Repository](https://github.com/trsdn/mcp-server-ppt) 2. Click **"New Pull Request"** 3. Select your feature branch 4. Fill out the PR template: @@ -122,23 +122,23 @@ The `main` branch is protected with: ### **Three-Tier Test Architecture** -ExcelMcp uses a **production-ready three-tier testing approach** with organized directory structure: +PptMcp uses a **production-ready three-tier testing approach** with organized directory structure: ``` tests/ -├── ExcelMcp.Core.Tests/ -│ ├── Unit/ # Fast tests, no Excel required (~2-5 sec) -│ ├── Integration/ # Medium speed, requires Excel (~1-15 min) +├── PptMcp.Core.Tests/ +│ ├── Unit/ # Fast tests, no PowerPoint required (~2-5 sec) +│ ├── Integration/ # Medium speed, requires PowerPoint (~1-15 min) │ └── RoundTrip/ # Slow, comprehensive workflows (~3-10 min each) -├── ExcelMcp.Diagnostics.Tests/ +├── PptMcp.Diagnostics.Tests/ │ └── Integration/Diagnostics/ # Research tests, manual only (excluded from CI) -├── ExcelMcp.McpServer.Tests/ +├── PptMcp.McpServer.Tests/ │ ├── Unit/ # Fast tests, no server required │ ├── Integration/ # Medium speed, requires MCP server │ └── RoundTrip/ # Slow, end-to-end protocol testing -└── ExcelMcp.CLI.Tests/ - ├── Unit/ # Fast tests, no Excel required - └── Integration/ # Medium speed, requires Excel & CLI +└── PptMcp.CLI.Tests/ + ├── Unit/ # Fast tests, no PowerPoint required + └── Integration/ # Medium speed, requires PowerPoint & CLI ``` ### **Development Workflow Commands** @@ -158,7 +158,7 @@ dotnet test --filter "Category=Integration&RunType!=OnDemand&Feature!=VBA&Featur **Session/Batch Code Changes (MANDATORY):** ```powershell -# When modifying ExcelSession.cs or ExcelBatch.cs +# When modifying PptSession.cs or PptBatch.cs dotnet test --filter "RunType=OnDemand" ``` @@ -167,13 +167,13 @@ dotnet test --filter "RunType=OnDemand" **⚠️ No Unit Tests** - See `docs/ADR-001-NO-UNIT-TESTS.md` for architectural rationale **Integration Tests (`Category=Integration`)** -- ✅ Test business logic with real Excel COM interaction +- ✅ Test business logic with real PowerPoint COM interaction - ✅ Medium speed (10-20 minutes for full suite) -- ✅ Requires Excel installation -- ✅ These ARE our unit tests (Excel COM cannot be mocked) +- ✅ Requires PowerPoint installation +- ✅ These ARE our unit tests (PowerPoint COM cannot be mocked) - ✅ Run specific features during development - ✅ Slow execution (3-10 minutes each) -- ✅ Verifies actual Excel state changes +- ✅ Verifies actual PowerPoint state changes - ✅ Comprehensive scenario coverage ### **Adding New Tests** @@ -187,24 +187,24 @@ When creating tests, follow these placement guidelines: [Trait("Layer", "Core")] public class CommandLogicTests { - // Tests business logic without Excel + // Tests business logic without PowerPoint } // Integration Test Example [Trait("Category", "Integration")] [Trait("Speed", "Medium")] [Trait("Feature", "PowerQuery")] -[Trait("RequiresExcel", "true")] +[Trait("RequiresPowerPoint", "true")] public class PowerQueryCommandsTests { - // Tests single Excel operations + // Tests single PowerPoint operations } // Round Trip Test Example [Trait("Category", "RoundTrip")] [Trait("Speed", "Slow")] [Trait("Feature", "EndToEnd")] -[Trait("RequiresExcel", "true")] +[Trait("RequiresPowerPoint", "true")] public class VbaWorkflowTests { // Tests complete workflows: import → run → verify → export @@ -226,7 +226,7 @@ dotnet build -c Release ``` **For Complex Features:** -- ✅ Add integration tests for all Excel operations +- ✅ Add integration tests for all PowerPoint operations - ✅ Test round-trip persistence (create → save → reload → verify) - ✅ Update documentation - ✅ No unit tests needed (see ADR-001-NO-UNIT-TESTS.md) @@ -296,7 +296,7 @@ When adding a new service category to Core: - ServiceRegistry class in Core - Command class in CLI.Generated - Registration entry in CliCommandRegistration -4. **Test** - verify `excelcli COMMAND_NAME --help` works +4. **Test** - verify `pptcli COMMAND_NAME --help` works ### **Why Hard-Coded Categories?** @@ -318,7 +318,7 @@ The categories are currently hard-coded in the CLI generator because: **Future improvement:** Could emit a manifest file from Core and parse it in CLI generator using source file inclusion. **For Complex Features:** -- ✅ Add integration tests for all Excel operations +- ✅ Add integration tests for all PowerPoint operations - ✅ Test round-trip persistence (create → save → reload → verify) - ✅ Update documentation - ✅ No unit tests needed (see ADR-001-NO-UNIT-TESTS.md) @@ -328,7 +328,7 @@ The categories are currently hard-coded in the CLI generator because: ### **CRITICAL: Keep server.json in Sync** -When modifying MCP Server functionality, **you must update** `src/ExcelMcp.McpServer/.mcp/server.json`: +When modifying MCP Server functionality, **you must update** `src/PptMcp.McpServer/.mcp/server.json`: #### **When to Update server.json:** @@ -344,13 +344,13 @@ When modifying MCP Server functionality, **you must update** `src/ExcelMcp.McpSe # After making MCP Server code changes, verify: # 1. Tool definitions match actual implementations -Compare-Object (Get-Content "src/ExcelMcp.McpServer/.mcp/server.json" | ConvertFrom-Json).tools (Get-ChildItem "src/ExcelMcp.McpServer/Tools/*.cs") +Compare-Object (Get-Content "src/PptMcp.McpServer/.mcp/server.json" | ConvertFrom-Json).tools (Get-ChildItem "src/PptMcp.McpServer/Tools/*.cs") # 2. Build succeeds with updated configuration -dotnet build src/ExcelMcp.McpServer/ExcelMcp.McpServer.csproj +dotnet build src/PptMcp.McpServer/PptMcp.McpServer.csproj # 3. Test MCP server starts without errors -dnx Sbroenne.ExcelMcp.McpServer --yes +dnx PptMcp.McpServer --yes ``` #### **server.json Structure:** @@ -384,7 +384,7 @@ dnx Sbroenne.ExcelMcp.McpServer --yes ```json // Add to server.json tools array { - "name": "excel_newtool", + "name": "ppt_newtool", "description": "New functionality description", "inputSchema": { ... } } @@ -450,8 +450,8 @@ When creating a PR, verify: ```powershell # Clone the repository -git clone https://github.com/sbroenne/mcp-server-excel.git -cd ExcelMcp +git clone https://github.com/trsdn/mcp-server-ppt.git +cd PptMcp # Install dependencies dotnet restore @@ -463,138 +463,22 @@ dotnet test dotnet build -c Release # Test the built executable -.\src\ExcelMcp.CLI\bin\Release\net10.0\excelcli.exe --version +.\src\PptMcp.CLI\bin\Release\net10.0\pptcli.exe --version ``` -## 📊 **Application Insights / Telemetry Setup** - -ExcelMcp uses Azure Application Insights (Classic SDK with WorkerService integration) for anonymous usage telemetry and crash reporting. Telemetry is **opt-out** (enabled by default in release builds). - -### **How It Works** - -The Application Insights connection string is **embedded at build time** via MSBuild - there is no runtime environment variable lookup. - -**Build-time flow:** -1. MSBuild reads `AppInsightsConnectionString` property (from `Directory.Build.props.user` or env var) -2. Generates `TelemetryConfig.g.cs` with the connection string as a `const string` -3. Compiled assembly contains the embedded connection string - -### **What is Tracked** - -- **Tool invocations**: Tool name, action, duration (ms), success/failure -- **Unhandled exceptions**: Exception type and redacted stack trace -- **User ID**: SHA256 hash of machine identity (anonymous, 16 chars) -- **Session ID**: Random GUID per process (8 chars) - -### **What is NOT Tracked** - -- File paths, file names, or file contents -- User identity, machine name, or IP address -- Excel data, formulas, or cell values -- Connection strings, credentials, or passwords - -### **Sensitive Data Redaction** - -All telemetry passes through `SensitiveDataRedactingProcessor` which removes: -- Windows file paths (`C:\Users\...` → `[REDACTED_PATH]`) -- UNC paths (`\\server\share\...` → `[REDACTED_PATH]`) -- Connection string secrets (`Password=...` → `[REDACTED_CREDENTIAL]`) -- Email addresses → `[REDACTED_EMAIL]` - -### **Local Development with Telemetry** - -To enable telemetry in local builds: - -```powershell -# 1. Copy the template file -Copy-Item "Directory.Build.props.user.template" "Directory.Build.props.user" - -# 2. Edit Directory.Build.props.user and add your connection string -# InstrumentationKey=xxx;IngestionEndpoint=... - -# 3. Build - connection string is embedded at compile time -dotnet build src/ExcelMcp.McpServer/ExcelMcp.McpServer.csproj - -# 4. Run - telemetry is automatically sent to Azure -dotnet run --project src/ExcelMcp.McpServer/ExcelMcp.McpServer.csproj -``` - -**Note:** `Directory.Build.props.user` is gitignored - your connection string won't be committed. - -### **Local Development without Telemetry** - -If you don't create `Directory.Build.props.user`, builds will have an empty connection string and telemetry will be disabled. This is the default for local development. - -### **Azure Resources Setup (Maintainers Only)** - -To deploy the Application Insights infrastructure: - -```powershell -# 1. Login to Azure -az login - -# 2. Deploy resources (creates RG, Log Analytics, App Insights) -.\infrastructure\azure\deploy-appinsights.ps1 -SubscriptionId "" - -# 3. Copy the connection string from output -# Output: "Connection String: InstrumentationKey=xxx;IngestionEndpoint=..." -``` - -### **GitHub Secret Configuration (Maintainers Only)** - -After deploying Azure resources: - -1. Go to GitHub repo → **Settings** → **Secrets and variables** → **Actions** -2. Add new secret: `APPINSIGHTS_CONNECTION_STRING` -3. Paste the connection string from deployment output - -The release workflow sets this as an environment variable, and MSBuild embeds it at build time. - -### **Telemetry Architecture** - -```text -Build Time: - MSBuild → reads AppInsightsConnectionString → generates TelemetryConfig.g.cs - -Runtime: - MCP Tool Invocation - │ - ▼ - ExcelMcpTelemetry.TrackToolInvocation() - │ (tracks: tool, action, duration, success) - ▼ - SensitiveDataRedactingProcessor - │ (removes: paths, credentials, emails) - ▼ - TelemetryClient → Application Insights -``` - -### **Files Overview** - -| File | Purpose | -|------|---------| -| `Telemetry/ExcelMcpTelemetry.cs` | Static helper for tracking events | -| `Telemetry/ExcelMcpTelemetryInitializer.cs` | Sets User.Id and Session.Id on telemetry | -| `Telemetry/SensitiveDataRedactingProcessor.cs` | Redacts PII before transmission | -| `Program.cs` | Application Insights WorkerService configuration | -| `ExcelMcp.McpServer.csproj` | MSBuild target that generates TelemetryConfig.g.cs | -| `Directory.Build.props.user.template` | Template for local dev connection string | -| `infrastructure/azure/appinsights.bicep` | Azure resource definitions | -| `infrastructure/azure/deploy-appinsights.ps1` | Deployment script | - ## ✂️ **Trimming and Native AOT Compatibility** ### **Why Trimming Is Not Supported** -ExcelMcp **cannot be trimmed** due to fundamental architectural constraints of Excel COM automation. The IL trimmer removes unused code at publish time, but Excel COM interop requires dynamic code paths that the trimmer cannot statically analyze. +PptMcp **cannot be trimmed** due to fundamental architectural constraints of PowerPoint COM automation. The IL trimmer removes unused code at publish time, but PowerPoint COM interop requires dynamic code paths that the trimmer cannot statically analyze. ### **Technical Constraints** **1. Runtime COM Activation** ```csharp -// This code CANNOT be trimmed - Excel type comes from Windows Registry at runtime -Type? excelType = Type.GetTypeFromProgID("Excel.Application"); -dynamic excel = Activator.CreateInstance(excelType)!; +// This code CANNOT be trimmed - PowerPoint type comes from Windows Registry at runtime +Type? pptType = Type.GetTypeFromProgID("PowerPoint.Application"); +dynamic ppt = Activator.CreateInstance(pptType)!; ``` The trimmer cannot know: @@ -603,20 +487,20 @@ The trimmer cannot know: **2. Late-Bound COM Calls** ```csharp -// All Excel operations use dynamic dispatch - the trimmer can't trace these calls -dynamic workbook = excel.Workbooks.Open(filePath); -dynamic sheet = workbook.Worksheets.Item(1); -sheet.Range["A1"].Value2 = "Hello"; +// All PowerPoint operations use dynamic dispatch - the trimmer can't trace these calls +dynamic presentation = ppt.Presentations.Open(filePath); +dynamic slide = presentation.Slides.Item(1); +slide.Shapes[1].TextFrame.TextRange.Text = "Hello"; ``` -**3. Excel is External** -- Excel is not a .NET assembly - it's an out-of-process COM server -- The .NET runtime uses the Dynamic Language Runtime (DLR) for all Excel calls +**3. PowerPoint is External** +- PowerPoint is not a .NET assembly - it's an out-of-process COM server +- The .NET runtime uses the Dynamic Language Runtime (DLR) for all PowerPoint calls - No static type information exists for the trimmer to analyze ### **What We DID Modernize** -While the Excel automation core cannot be trimmed, we modernized the OLE Message Filter to use .NET source-generated COM interop: +While the PowerPoint automation core cannot be trimmed, we modernized the OLE Message Filter to use .NET source-generated COM interop: | Component | Before | After | |-----------|--------|-------| @@ -642,17 +526,17 @@ The following warnings are suppressed in `Directory.Build.props` because they ca ### **Can We Ever Support Trimming?** **No**, unless one of these happens: -1. **Excel exposes a .NET API** - Microsoft would need to create a managed Excel SDK +1. **PowerPoint exposes a .NET API** - Microsoft would need to create a managed PowerPoint SDK 2. **We abandon COM** - Would require a completely different architecture (file-based only, no live automation) -3. **Excel is replaced** - Use a different spreadsheet engine with .NET bindings +3. **PowerPoint is replaced** - Use a different presentation engine with .NET bindings -**The current architecture is the standard approach** for Excel automation in .NET and is used by thousands of applications. Trimming is simply not compatible with COM automation. +**The current architecture is the standard approach** for PowerPoint automation in .NET and is used by thousands of applications. Trimming is simply not compatible with COM automation. ### **Alternatives for Smaller Binaries** If deployment size is a concern: - Use **framework-dependent** deployment (default) - smallest option (~15 MB) -- The .NET runtime is typically already installed on Windows machines with Excel +- The .NET runtime is typically already installed on Windows machines with PowerPoint - Self-contained deployment is only needed for isolated environments ## 📞 **Need Help?** diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 7be8e57f..69c3fc98 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -1,12 +1,12 @@ -# Installation Guide - ExcelMcp +# Installation Guide - PptMcp -Complete installation instructions for the ExcelMcp MCP Server and CLI tool. +Complete installation instructions for the PptMcp MCP Server and CLI tool. ## System Requirements ### Required - **Windows OS** (Windows 10 or later) -- **Microsoft Excel 2016 or later** (Desktop version - Office 365, Professional Plus, or Standalone) +- **Microsoft PowerPoint 2016 or later** (Desktop version - Office 365, Professional Plus, or Standalone) - **.NET 10 Runtime or SDK** (not required for VS Code Extension or MCPB - they bundle it) ### Optional (for specific features) @@ -31,7 +31,7 @@ Use this order to avoid setup confusion: - Claude Desktop MCPB - Manual MCP setup (all other MCP clients) 2. **Validate MCP setup** (run the quick test prompt in Step 4 of manual setup, or test in your client after extension/MCPB install) -3. **Optional:** install CLI (`excelcli`) for scripting/RPA +3. **Optional:** install CLI (`pptcli`) for scripting/RPA 4. **Optional:** install agent skills for non-extension environments ### VS Code Extension (Easiest - One-Click Setup) @@ -41,16 +41,16 @@ Use this order to avoid setup confusion: 1. **Install the Extension** - Open VS Code - Press `Ctrl+Shift+X` (Extensions) - - Search for **"ExcelMcp"** + - Search for **"PptMcp"** - Click **Install** 2. **That's It!** - Bundles self-contained MCP server and CLI (no .NET runtime or SDK needed) - Auto-configures GitHub Copilot - - Registers agent skills (excel-mcp + excel-cli) via `chatSkills` + - Registers agent skills (ppt-mcp + ppt-cli) via `chatSkills` - Shows quick start guide on first launch -**Marketplace Link:** [Excel MCP VS Code Extension](https://marketplace.visualstudio.com/items?itemName=sbroenne.excel-mcp) +**Marketplace Link:** [PowerPoint MCP VS Code Extension](https://marketplace.visualstudio.com/items?itemName=trsdn.ppt-mcp) --- @@ -58,7 +58,7 @@ Use this order to avoid setup confusion: **Best for:** Claude Desktop users who want the simplest installation -1. Download `excel-mcp-{version}.mcpb` from the [latest release](https://github.com/sbroenne/mcp-server-excel/releases/latest) +1. Download `ppt-mcp-{version}.mcpb` from the [latest release](https://github.com/trsdn/mcp-server-ppt/releases/latest) 2. Double-click the `.mcpb` file (or drag-and-drop onto Claude Desktop) 3. Restart Claude Desktop @@ -86,19 +86,19 @@ winget install Microsoft.DotNet.Runtime.10 **Manual Download:** [.NET 10 Downloads](https://dotnet.microsoft.com/download/dotnet/10.0) -### Step 2: Install ExcelMcp MCP Server +### Step 2: Install PptMcp MCP Server ```powershell -# Install MCP Server tool (command: mcp-excel) -dotnet tool install --global Sbroenne.ExcelMcp.McpServer +# Install MCP Server tool (command: mcp-ppt) +dotnet tool install --global PptMcp.McpServer # Verify installation -dotnet tool list --global | Select-String "ExcelMcp" +dotnet tool list --global | Select-String "PptMcp" ``` -> **Optional:** If you also want the standalone CLI command (`excelcli`) for scripting/RPA, install it separately: +> **Optional:** If you also want the standalone CLI command (`pptcli`) for scripting/RPA, install it separately: > ```powershell -> dotnet tool install --global Sbroenne.ExcelMcp.CLI +> dotnet tool install --global PptMcp.CLI > ``` ### Step 3: Configure Your MCP Client @@ -108,27 +108,27 @@ dotnet tool list --global | Select-String "ExcelMcp" Use [`add-mcp`](https://github.com/neondatabase/add-mcp) to configure all detected coding agents with a single command: ```powershell -npx add-mcp "mcp-excel" --name excel-mcp +npx add-mcp "mcp-ppt" --name ppt-mcp ``` This auto-detects and configures **Cursor, VS Code, Claude Code, Claude Desktop, Codex, Zed, Gemini CLI**, and more. Use flags to customize: ```powershell # Configure specific agents only -npx add-mcp "mcp-excel" --name excel-mcp -a cursor -a claude-code +npx add-mcp "mcp-ppt" --name ppt-mcp -a cursor -a claude-code # Configure globally (user-wide, all projects) -npx add-mcp "mcp-excel" --name excel-mcp -g +npx add-mcp "mcp-ppt" --name ppt-mcp -g # Non-interactive (skip prompts) -npx add-mcp "mcp-excel" --name excel-mcp --all -y +npx add-mcp "mcp-ppt" --name ppt-mcp --all -y ``` > **Requires:** [Node.js](https://nodejs.org/) for `npx`. Install with `winget install OpenJS.NodeJS.LTS` if not already available. No permanent `add-mcp` installation needed — `npx` downloads, runs, and cleans up automatically. #### Option B: Manual Configuration -**Quick Start:** Ready-to-use config files for all clients are available in [`examples/mcp-configs/`](https://github.com/sbroenne/mcp-server-excel/tree/main/examples/mcp-configs/) +**Quick Start:** Ready-to-use config files for all clients are available in [`examples/mcp-configs/`](https://github.com/trsdn/mcp-server-ppt/tree/main/examples/mcp-configs/) **For GitHub Copilot (VS Code):** @@ -137,8 +137,8 @@ Create `.vscode/mcp.json` in your workspace: ```json { "servers": { - "excel-mcp": { - "command": "mcp-excel" + "ppt-mcp": { + "command": "mcp-ppt" } } } @@ -151,8 +151,8 @@ Create `.mcp.json` in your solution directory or `%USERPROFILE%\.mcp.json`: ```json { "servers": { - "excel-mcp": { - "command": "mcp-excel" + "ppt-mcp": { + "command": "mcp-ppt" } } } @@ -162,13 +162,13 @@ Create `.mcp.json` in your solution directory or `%USERPROFILE%\.mcp.json`: 1. Locate config file: `%APPDATA%\Claude\claude_desktop_config.json` 2. If file doesn't exist, create it with the content below -3. If file exists, merge the `excel-mcp` entry into your existing `mcpServers` section +3. If file exists, merge the `ppt-mcp` entry into your existing `mcpServers` section ```json { "mcpServers": { - "excel-mcp": { - "command": "mcp-excel", + "ppt-mcp": { + "command": "mcp-ppt", "args": [], "env": {} } @@ -188,8 +188,8 @@ Create `.mcp.json` in your solution directory or `%USERPROFILE%\.mcp.json`: ```json { "mcpServers": { - "excel-mcp": { - "command": "mcp-excel", + "ppt-mcp": { + "command": "mcp-ppt", "args": [], "env": {} } @@ -208,8 +208,8 @@ Create `.mcp.json` in your solution directory or `%USERPROFILE%\.mcp.json`: ```json { "mcpServers": { - "excel-mcp": { - "command": "mcp-excel", + "ppt-mcp": { + "command": "mcp-ppt", "args": [], "env": {} } @@ -228,8 +228,8 @@ Create `.mcp.json` in your solution directory or `%USERPROFILE%\.mcp.json`: ```json { "mcpServers": { - "excel-mcp": { - "command": "mcp-excel", + "ppt-mcp": { + "command": "mcp-ppt", "args": [], "env": {} } @@ -243,16 +243,16 @@ Create `.mcp.json` in your solution directory or `%USERPROFILE%\.mcp.json`: Restart your MCP client, then ask: ``` -Create an empty Excel file called "test.xlsx" +Create an empty PowerPoint file called "test.pptx" ``` If it works, you're all set! 🎉 **💡 Tip:** Want to watch the AI work? Ask: ``` -Show me Excel while you work on test.xlsx +Show me PowerPoint while you work on test.pptx ``` -This opens Excel visibly so you can see every change in real-time - great for debugging and demos! +This opens PowerPoint visibly so you can see every change in real-time - great for debugging and demos! --- @@ -264,10 +264,10 @@ This opens Excel visibly so you can see every change in real-time - great for de ```powershell # Install CLI as a separate .NET tool -dotnet tool install --global Sbroenne.ExcelMcp.CLI +dotnet tool install --global PptMcp.CLI # Verify CLI is available -excelcli --version +pptcli --version ``` > **⚠️ Version Sync:** If you install both MCP Server and CLI, keep both packages on the same version. @@ -275,15 +275,15 @@ excelcli --version ### Quick Test ```powershell -# Session-based workflow (keeps Excel open between commands) -excelcli -q session open test.xlsx # Returns session ID -excelcli -q sheet list --session # List worksheets -excelcli -q session close --session --save +# Session-based workflow (keeps PowerPoint open between commands) +pptcli -q session open test.pptx # Returns session ID +pptcli -q slide list --session # List slides +pptcli -q session close --session --save ``` > **💡 Tip:** Use `-q` (quiet mode) to suppress banner and get JSON output only - perfect for scripting and automation. -**CLI Documentation:** [CLI Guide](https://github.com/sbroenne/mcp-server-excel/blob/main/src/ExcelMcp.CLI/README.md) +**CLI Documentation:** [CLI Guide](https://github.com/trsdn/mcp-server-ppt/blob/main/src/PptMcp.CLI/README.md) --- @@ -295,17 +295,17 @@ Skills are auto-installed by the VS Code extension. For other platforms: ```powershell # CLI skill (for coding agents - token-efficient workflows) -npx skills add sbroenne/mcp-server-excel --skill excel-cli +npx skills add trsdn/mcp-server-ppt --skill ppt-cli # MCP skill (for conversational AI - rich tool schemas) -npx skills add sbroenne/mcp-server-excel --skill excel-mcp +npx skills add trsdn/mcp-server-ppt --skill ppt-mcp # Install for specific agents -npx skills add sbroenne/mcp-server-excel --skill excel-cli -a cursor -npx skills add sbroenne/mcp-server-excel --skill excel-mcp -a claude-code +npx skills add trsdn/mcp-server-ppt --skill ppt-cli -a cursor +npx skills add trsdn/mcp-server-ppt --skill ppt-mcp -a claude-code # Install globally (user-wide) -npx skills add sbroenne/mcp-server-excel --skill excel-cli --global +npx skills add trsdn/mcp-server-ppt --skill ppt-cli --global ``` **Supports 43+ agents** including claude-code, github-copilot, cursor, windsurf, gemini-cli, codex, goose, cline, continue, replit, and more. @@ -314,16 +314,16 @@ npx skills add sbroenne/mcp-server-excel --skill excel-cli --global --- -## Updating ExcelMcp +## Updating PptMcp ### Check Installed Version **MCP Server and CLI:** ```powershell -dotnet tool list --global | Select-String "ExcelMcp" +dotnet tool list --global | Select-String "PptMcp" # Or check CLI version -excelcli --version +pptcli --version ``` ### Update Installed Tools @@ -332,18 +332,18 @@ excelcli --version **Step 1: Update both tools** ```powershell -dotnet tool update --global Sbroenne.ExcelMcp.McpServer -dotnet tool update --global Sbroenne.ExcelMcp.CLI +dotnet tool update --global PptMcp.McpServer +dotnet tool update --global PptMcp.CLI ``` **Step 2: Verify update** ```powershell # Check installed version -dotnet tool list --global | Select-String "ExcelMcp" +dotnet tool list --global | Select-String "PptMcp" # Verify both tools work -excelcli --version -mcp-excel --version +pptcli --version +mcp-ppt --version ``` **Step 3: Restart your MCP client** @@ -357,15 +357,15 @@ mcp-excel --version **Error: "Tool not found"** ```powershell # The tool may need to be reinstalled -dotnet tool uninstall --global Sbroenne.ExcelMcp.McpServer -dotnet tool install --global Sbroenne.ExcelMcp.McpServer +dotnet tool uninstall --global PptMcp.McpServer +dotnet tool install --global PptMcp.McpServer ``` **Error: "Access denied"** - Run PowerShell as Administrator - Or install in user directory (not global): ```powershell -dotnet tool update --global Sbroenne.ExcelMcp.McpServer --install-dir ~/.dotnet/tools +dotnet tool update --global PptMcp.McpServer --install-dir ~/.dotnet/tools ``` #### MCP Server Still Running Old Version @@ -378,8 +378,8 @@ dotnet tool update --global Sbroenne.ExcelMcp.McpServer --install-dir ~/.dotnet/ **Still not working?** ```powershell # Reinstall the tool -dotnet tool uninstall --global Sbroenne.ExcelMcp.McpServer -dotnet tool install --global Sbroenne.ExcelMcp.McpServer +dotnet tool uninstall --global PptMcp.McpServer +dotnet tool install --global PptMcp.McpServer ``` ### Rollback to Previous Version @@ -388,18 +388,18 @@ If an update causes issues, you can downgrade: ```powershell # Uninstall current version -dotnet tool uninstall --global Sbroenne.ExcelMcp.McpServer +dotnet tool uninstall --global PptMcp.McpServer # Install specific version -dotnet tool install --global Sbroenne.ExcelMcp.McpServer --version 1.2.3 +dotnet tool install --global PptMcp.McpServer --version 1.2.3 # Replace 1.2.3 with the version you want ``` ### Check What's New Before updating, check the release notes: -- **GitHub Releases:** https://github.com/sbroenne/mcp-server-excel/releases -- **Changelog:** https://github.com/sbroenne/mcp-server-excel/blob/main/CHANGELOG.md +- **GitHub Releases:** https://github.com/trsdn/mcp-server-ppt/releases +- **Changelog:** https://github.com/trsdn/mcp-server-ppt/blob/main/CHANGELOG.md --- @@ -415,40 +415,40 @@ Before updating, check the release notes: **Check if tool is installed:** ```powershell -dotnet tool list --global | Select-String "ExcelMcp" +dotnet tool list --global | Select-String "PptMcp" ``` **Reinstall if missing:** ```powershell -dotnet tool uninstall --global Sbroenne.ExcelMcp.McpServer -dotnet tool install --global Sbroenne.ExcelMcp.McpServer +dotnet tool uninstall --global PptMcp.McpServer +dotnet tool install --global PptMcp.McpServer ``` -#### 3. "Workbook is locked" or "Cannot open file" +#### 3. "Presentation is locked" or "Cannot open file" -**Solution:** Close all Excel windows before running ExcelMcp +**Solution:** Close all PowerPoint windows before running PptMcp -ExcelMcp requires exclusive access to workbooks (Excel COM limitation). +PptMcp requires exclusive access to presentations (PowerPoint COM limitation). ## Uninstallation ### Uninstall MCP Server ```powershell -dotnet tool uninstall --global Sbroenne.ExcelMcp.McpServer +dotnet tool uninstall --global PptMcp.McpServer ``` ### Uninstall CLI ```powershell -dotnet tool uninstall --global Sbroenne.ExcelMcp.CLI +dotnet tool uninstall --global PptMcp.CLI ``` --- ## Getting Help -- **Documentation:** [GitHub Repository](https://github.com/sbroenne/mcp-server-excel) -- **Issues:** [GitHub Issues](https://github.com/sbroenne/mcp-server-excel/issues) -- **Contributing:** [Contributing Guide](https://github.com/sbroenne/mcp-server-excel/blob/main/docs/CONTRIBUTING.md) +- **Documentation:** [GitHub Repository](https://github.com/trsdn/mcp-server-ppt) +- **Issues:** [GitHub Issues](https://github.com/trsdn/mcp-server-ppt/issues) +- **Contributing:** [Contributing Guide](https://github.com/trsdn/mcp-server-ppt/blob/main/docs/CONTRIBUTING.md) --- @@ -456,19 +456,19 @@ dotnet tool uninstall --global Sbroenne.ExcelMcp.CLI After installation: -1. **Learn the basics:** Try simple commands like creating worksheets, setting values -2. **Explore features:** See [README](https://github.com/sbroenne/mcp-server-excel#readme) for complete feature list +1. **Learn the basics:** Try simple commands like creating slides, setting values +2. **Explore features:** See [README](https://github.com/trsdn/mcp-server-ppt#readme) for complete feature list 3. **Read the guides:** - - [MCP Server Guide](https://github.com/sbroenne/mcp-server-excel/blob/main/src/ExcelMcp.McpServer/README.md) - - [CLI Guide](https://github.com/sbroenne/mcp-server-excel/blob/main/src/ExcelMcp.CLI/README.md) - - [Agent Skills](https://github.com/sbroenne/mcp-server-excel/blob/main/skills/excel-mcp/SKILL.md) - Cross-platform AI guidance + - [MCP Server Guide](https://github.com/trsdn/mcp-server-ppt/blob/main/src/PptMcp.McpServer/README.md) + - [CLI Guide](https://github.com/trsdn/mcp-server-ppt/blob/main/src/PptMcp.CLI/README.md) + - [Agent Skills](https://github.com/trsdn/mcp-server-ppt/blob/main/skills/ppt-mcp/SKILL.md) - Cross-platform AI guidance 4. **Join the community:** Star the repo, report issues, contribute improvements --- ## Agent Skills (Optional) -Agent Skills provide domain-specific guidance to AI coding assistants, helping them use Excel MCP Server more effectively. +Agent Skills provide domain-specific guidance to AI coding assistants, helping them use PowerPoint MCP Server more effectively. > **Note:** Agent Skills are for **coding agents** (GitHub Copilot, Claude Code, Cursor). **Claude Desktop** uses MCP Prompts instead (included automatically via the MCP Server). @@ -476,8 +476,8 @@ Agent Skills provide domain-specific guidance to AI coding assistants, helping t | Skill | Target | Best For | |-------|--------|----------| -| **excel-cli** | CLI Tool | **Coding agents** (Copilot, Cursor, Windsurf) - token-efficient, `excelcli --help` discoverable | -| **excel-mcp** | MCP Server | **Conversational AI** (Claude Desktop, VS Code Chat) - rich tool schemas, exploratory workflows | +| **ppt-cli** | CLI Tool | **Coding agents** (Copilot, Cursor, Windsurf) - token-efficient, `pptcli --help` discoverable | +| **ppt-mcp** | MCP Server | **Conversational AI** (Claude Desktop, VS Code Chat) - rich tool schemas, exploratory workflows | **VS Code Extension:** Skills are installed automatically to `~/.copilot/skills/`. @@ -485,37 +485,37 @@ Agent Skills provide domain-specific guidance to AI coding assistants, helping t ```powershell # Install CLI skill (recommended for coding agents - Copilot, Cursor, Windsurf, Codex, etc.) -npx skills add sbroenne/mcp-server-excel --skill excel-cli +npx skills add trsdn/mcp-server-ppt --skill ppt-cli # Install MCP skill (for conversational AI - Claude Desktop, VS Code Chat) -npx skills add sbroenne/mcp-server-excel --skill excel-mcp +npx skills add trsdn/mcp-server-ppt --skill ppt-mcp -# Interactive install - prompts to select excel-cli, excel-mcp, or both -npx skills add sbroenne/mcp-server-excel +# Interactive install - prompts to select ppt-cli, ppt-mcp, or both +npx skills add trsdn/mcp-server-ppt # Install specific skill directly -npx skills add sbroenne/mcp-server-excel --skill excel-cli # Coding agents -npx skills add sbroenne/mcp-server-excel --skill excel-mcp # Conversational AI +npx skills add trsdn/mcp-server-ppt --skill ppt-cli # Coding agents +npx skills add trsdn/mcp-server-ppt --skill ppt-mcp # Conversational AI # Install both skills -npx skills add sbroenne/mcp-server-excel --skill '*' +npx skills add trsdn/mcp-server-ppt --skill '*' # Target specific agent (optional - auto-detects if omitted) -npx skills add sbroenne/mcp-server-excel --skill excel-cli -a cursor -npx skills add sbroenne/mcp-server-excel --skill excel-mcp -a claude-code +npx skills add trsdn/mcp-server-ppt --skill ppt-cli -a cursor +npx skills add trsdn/mcp-server-ppt --skill ppt-mcp -a claude-code ``` **Manual Installation:** -1. Download `excel-skills-v{version}.zip` from [GitHub Releases](https://github.com/sbroenne/mcp-server-excel/releases/latest) +1. Download `ppt-skills-v{version}.zip` from [GitHub Releases](https://github.com/trsdn/mcp-server-ppt/releases/latest) 2. The package contains both skills: - - `skills/excel-cli/` - for coding agents (Copilot, Cursor, Windsurf) - - `skills/excel-mcp/` - for conversational AI (Claude Desktop, VS Code Chat) + - `skills/ppt-cli/` - for coding agents (Copilot, Cursor, Windsurf) + - `skills/ppt-mcp/` - for conversational AI (Claude Desktop, VS Code Chat) 3. Extract the skill(s) you need to your AI assistant's skills directory: - - Copilot: `~/.copilot/skills/excel-cli/` or `~/.copilot/skills/excel-mcp/` - - Claude Code: `.claude/skills/excel-cli/` or `.claude/skills/excel-mcp/` - - Cursor: `.cursor/skills/excel-cli/` or `.cursor/skills/excel-mcp/` + - Copilot: `~/.copilot/skills/ppt-cli/` or `~/.copilot/skills/ppt-mcp/` + - Claude Code: `.claude/skills/ppt-cli/` or `.claude/skills/ppt-mcp/` + - Cursor: `.cursor/skills/ppt-cli/` or `.cursor/skills/ppt-mcp/` -**See:** [Agent Skills Documentation](https://github.com/sbroenne/mcp-server-excel/blob/main/skills/README.md) +**See:** [Agent Skills Documentation](https://github.com/trsdn/mcp-server-ppt/blob/main/skills/README.md) --- diff --git a/docs/MCP_REGISTRY_PUBLISHING.md b/docs/MCP_REGISTRY_PUBLISHING.md index 062f841f..ee77f2d8 100644 --- a/docs/MCP_REGISTRY_PUBLISHING.md +++ b/docs/MCP_REGISTRY_PUBLISHING.md @@ -1,10 +1,10 @@ # MCP Registry Publishing Guide -This document describes how the ExcelMcp server is published to the [Model Context Protocol (MCP) Registry](https://registry.modelcontextprotocol.io/). +This document describes how the PptMcp server is published to the [Model Context Protocol (MCP) Registry](https://registry.modelcontextprotocol.io/). ## Overview -The ExcelMcp server is automatically published to the MCP Registry whenever a new release is tagged with the format `v*` (e.g., `v1.0.10`). This is handled by the GitHub Actions workflow `.github/workflows/release-mcp-server.yml`. +The PptMcp server is automatically published to the MCP Registry whenever a new release is tagged with the format `v*` (e.g., `v1.0.10`). This is handled by the GitHub Actions workflow `.github/workflows/release-mcp-server.yml`. **Note**: The same tag also triggers CLI release - both MCP Server and CLI are released together with unified versioning. @@ -12,21 +12,21 @@ The ExcelMcp server is automatically published to the MCP Registry whenever a ne ### server.json -Location: `src/ExcelMcp.McpServer/.mcp/server.json` +Location: `src/PptMcp.McpServer/.mcp/server.json` This is the MCP registry metadata file that describes the server: ```json { "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", - "name": "io.github.sbroenne/mcp-server-excel", - "title": "Excel COM Automation", - "description": "Excel COM automation - Power Query, DAX measures, VBA, Tables, ranges, connections", + "name": "io.github.trsdn/mcp-server-ppt", + "title": "PowerPoint COM Automation", + "description": "PowerPoint COM automation - Slides, shapes, text, charts, tables, animations, transitions, VBA", "version": "1.0.0", "packages": [ { "registryType": "nuget", - "identifier": "Sbroenne.ExcelMcp.McpServer", + "identifier": "PptMcp.McpServer", "version": "1.0.0", "transport": { "type": "stdio" @@ -34,14 +34,14 @@ This is the MCP registry metadata file that describes the server: } ], "repository": { - "url": "https://github.com/sbroenne/mcp-server-excel", + "url": "https://github.com/trsdn/mcp-server-ppt", "source": "github" } } ``` Key fields: -- `name`: Registry namespace (uses GitHub namespace `io.github.sbroenne/*`) +- `name`: Registry namespace (uses GitHub namespace `io.github.trsdn/*`) - `title`: Human-readable name - `description`: Brief description of capabilities - `version`: Server version (automatically updated by release workflow) @@ -51,11 +51,11 @@ Key fields: For NuGet packages, the MCP Registry validates ownership by checking for `mcp-name:` in the package README. -Location: `src/ExcelMcp.McpServer/README.md` +Location: `src/PptMcp.McpServer/README.md` The README includes this validation metadata: ```markdown - + ``` This HTML comment is invisible to users but allows the registry to verify the package belongs to this server. @@ -73,7 +73,7 @@ When a tag like `mcp-v1.0.10` is pushed, the workflow: ### 2. Build and Test - Restores dependencies - Builds the MCP server in Release configuration -- Skips tests (they require Excel) +- Skips tests (they require PowerPoint) ### 3. NuGet Publishing - Packs the NuGet package @@ -105,10 +105,10 @@ Uses **NuGet Trusted Publishing** via OIDC: - `NUGET_USER`: Your NuGet.org username (profile name) **NuGet.org Configuration:** -- Package: `Sbroenne.ExcelMcp.McpServer` +- Package: `PptMcp.McpServer` - Trusted Publisher: GitHub Actions -- Owner: `sbroenne` -- Repository: `mcp-server-excel` +- Owner: `trsdn` +- Repository: `mcp-server-ppt` - Workflow: `release-mcp-server.yml` ### MCP Registry Authentication @@ -142,9 +142,9 @@ The workflow has `id-token: write` permission enabled for OIDC authentication. - Verify all steps complete successfully 4. **Verify publication** - - **NuGet**: https://www.nuget.org/packages/Sbroenne.ExcelMcp.McpServer - - **MCP Registry**: https://registry.modelcontextprotocol.io/servers/io.github.sbroenne/mcp-server-excel - - **GitHub Release**: https://github.com/sbroenne/mcp-server-excel/releases + - **NuGet**: https://www.nuget.org/packages/PptMcp.McpServer + - **MCP Registry**: https://registry.modelcontextprotocol.io/servers/io.github.trsdn/mcp-server-ppt + - **GitHub Release**: https://github.com/trsdn/mcp-server-ppt/releases ### Version Numbering @@ -176,12 +176,12 @@ The workflow has `id-token: write` permission enabled for OIDC authentication. **Manual Solution**: - Wait 5-10 minutes after NuGet publication for full CDN propagation -- Verify README is accessible: `https://api.nuget.org/v3-flatcontainer/sbroenne.excelmcp.mcpserver/{version}/readme` +- Verify README is accessible: `https://api.nuget.org/v3-flatcontainer/PptMcp.mcpserver/{version}/readme` - Manually publish to MCP Registry using the manual publishing process below **Verification Steps**: 1. Check that `mcp-name:` is present in the package README -2. Verify the mcp-name matches the server.json name exactly: `io.github.sbroenne/mcp-server-excel` +2. Verify the mcp-name matches the server.json name exactly: `io.github.trsdn/mcp-server-ppt` 3. Ensure NuGet package has been published successfully **Note**: As of the latest workflow update, MCP Registry publishing failures do not block the release process. The NuGet package will still be published successfully, and you can manually publish to the MCP Registry later if needed. @@ -210,7 +210,7 @@ The workflow has `id-token: write` permission enabled for OIDC authentication. Once published, the server will be: 1. **Discoverable**: Users can search for it in MCP-compatible clients -2. **Auto-installable**: `dnx Sbroenne.ExcelMcp.McpServer --yes` +2. **Auto-installable**: `dnx PptMcp.McpServer --yes` 3. **Version-managed**: Users can install specific versions 4. **Documented**: Description and README visible in registry @@ -232,11 +232,11 @@ tar xf mcp-publisher.tar.gz ``` ### 3. Update server.json Version -Edit `src/ExcelMcp.McpServer/.mcp/server.json` and update the version fields. +Edit `src/PptMcp.McpServer/.mcp/server.json` and update the version fields. ### 4. Publish ```powershell -cd src/ExcelMcp.McpServer +cd src/PptMcp.McpServer ../../mcp-publisher publish ``` diff --git a/docs/NUGET-GUIDE.md b/docs/NUGET-GUIDE.md index 88007ebc..0e528699 100644 --- a/docs/NUGET-GUIDE.md +++ b/docs/NUGET-GUIDE.md @@ -1,6 +1,6 @@ -# NuGet Publishing Guide for ExcelMcp +# NuGet Publishing Guide for PptMcp -Complete guide for publishing and managing all ExcelMcp NuGet packages using OIDC Trusted Publishing. +Complete guide for publishing and managing all PptMcp NuGet packages using OIDC Trusted Publishing. ## Table of Contents @@ -17,23 +17,23 @@ Complete guide for publishing and managing all ExcelMcp NuGet packages using OID ## Published Packages -ExcelMcp publishes two NuGet packages (unified release): +PptMcp publishes two NuGet packages (unified release): -### 1. Sbroenne.ExcelMcp.McpServer (.NET Tool) +### 1. PptMcp.McpServer (.NET Tool) - **Package Type**: .NET Global Tool (executable) - **Purpose**: MCP server for AI assistant integration - **Tag Pattern**: `v*` (e.g., `v1.2.0`) - **unified with CLI** - **Workflow**: `.github/workflows/release-mcp-server.yml` (handles both packages) -- **NuGet Page**: https://www.nuget.org/packages/Sbroenne.ExcelMcp.McpServer -- **Installation**: `dotnet tool install --global Sbroenne.ExcelMcp.McpServer` +- **NuGet Page**: https://www.nuget.org/packages/PptMcp.McpServer +- **Installation**: `dotnet tool install --global PptMcp.McpServer` -### 2. Sbroenne.ExcelMcp.CLI (.NET Tool) +### 2. PptMcp.CLI (.NET Tool) - **Package Type**: .NET Global Tool (executable) -- **Purpose**: Command-line interface for Excel automation +- **Purpose**: Command-line interface for PowerPoint automation - **Tag Pattern**: `v*` (e.g., `v1.2.0`) - **unified with MCP Server** - **Workflow**: `.github/workflows/release-mcp-server.yml` (handles both packages) -- **NuGet Page**: https://www.nuget.org/packages/Sbroenne.ExcelMcp.CLI -- **Installation**: `dotnet tool install --global Sbroenne.ExcelMcp.CLI` +- **NuGet Page**: https://www.nuget.org/packages/PptMcp.CLI +- **Installation**: `dotnet tool install --global PptMcp.CLI` **Note**: MCP Server and CLI are always released together with the same version number. Core and ComInterop libraries are internal dependencies and not separately published to NuGet. @@ -61,7 +61,7 @@ Trusted Publishing uses short-lived OIDC tokens instead of long-lived API keys f ↓ 2. GitHub Actions Workflow Triggered (release-mcp-server.yml) └─> Generates OIDC token with claims: - • Repository: sbroenne/mcp-server-excel + • Repository: trsdn/mcp-server-ppt • Workflow: release-mcp-server.yml • Actor: (whoever triggered) ↓ @@ -89,7 +89,7 @@ Trusted Publishing uses short-lived OIDC tokens instead of long-lived API keys f Add your NuGet.org username as a repository secret (one-time setup): 1. **Go to Repository Settings** - - Navigate to: https://github.com/sbroenne/mcp-server-excel/settings/secrets/actions + - Navigate to: https://github.com/trsdn/mcp-server-ppt/settings/secrets/actions - Or: Repository → Settings → Secrets and variables → Actions 2. **Add Repository Secret** @@ -106,10 +106,10 @@ Trusted publishing requires packages to exist on NuGet.org before configuration. ```powershell # Build the package -dotnet pack src/ExcelMcp.ComInterop/ExcelMcp.ComInterop.csproj -c Release -o ./nupkg +dotnet pack src/PptMcp.ComInterop/PptMcp.ComInterop.csproj -c Release -o ./nupkg # Publish using your NuGet API key (first time only) -dotnet nuget push ./nupkg/Sbroenne.ExcelMcp.ComInterop.1.0.0.nupkg \ +dotnet nuget push ./nupkg/PptMcp.ComInterop.1.0.0.nupkg \ --api-key YOUR_API_KEY \ --source https://api.nuget.org/v3/index.json ``` @@ -128,29 +128,29 @@ For **each package**, configure a trusted publisher: #### ComInterop Library -1. Go to: https://www.nuget.org/packages/Sbroenne.ExcelMcp.ComInterop/manage +1. Go to: https://www.nuget.org/packages/PptMcp.ComInterop/manage 2. Click "Trusted Publishers" tab → "Add Trusted Publisher" 3. Select "GitHub Actions" 4. Enter: - - **Owner**: `sbroenne` - - **Repository**: `mcp-server-excel` + - **Owner**: `trsdn` + - **Repository**: `mcp-server-ppt` - **Workflow**: `release-cominterop.yml` - **Environment**: *(leave empty)* 5. Click "Add" #### Core Library -1. Go to: https://www.nuget.org/packages/Sbroenne.ExcelMcp.Core/manage +1. Go to: https://www.nuget.org/packages/PptMcp.Core/manage 2. Same steps, use workflow: `release-core.yml` #### MCP Server -1. Go to: https://www.nuget.org/packages/Sbroenne.ExcelMcp.McpServer/manage +1. Go to: https://www.nuget.org/packages/PptMcp.McpServer/manage 2. Same steps, use workflow: `release-mcp-server.yml` #### CLI Tool -1. Go to: https://www.nuget.org/packages/Sbroenne.ExcelMcp.CLI/manage +1. Go to: https://www.nuget.org/packages/PptMcp.CLI/manage 2. Same steps, use workflow: `release-cli.yml` ### Step 4: Verify Configuration @@ -205,7 +205,7 @@ git tag cli-v1.1.0 git push origin cli-v1.1.0 # 5. Monitor workflows -# - Go to: https://github.com/sbroenne/mcp-server-excel/actions +# - Go to: https://github.com/trsdn/mcp-server-ppt/actions # - Watch release workflows run # - Verify NuGet publishing succeeds # - Verify GitHub releases created @@ -259,30 +259,30 @@ Release 4: cominterop-v1.1.0, core-v1.2.0, mcp-v1.2.0, cli-v1.2.0 (Core + wrapp ```powershell # Build all packages -dotnet pack src/ExcelMcp.ComInterop/ExcelMcp.ComInterop.csproj -c Release -o ./nupkg -dotnet pack src/ExcelMcp.Core/ExcelMcp.Core.csproj -c Release -o ./nupkg -dotnet pack src/ExcelMcp.McpServer/ExcelMcp.McpServer.csproj -c Release -o ./nupkg -dotnet pack src/ExcelMcp.CLI/ExcelMcp.CLI.csproj -c Release -o ./nupkg +dotnet pack src/PptMcp.ComInterop/PptMcp.ComInterop.csproj -c Release -o ./nupkg +dotnet pack src/PptMcp.Core/PptMcp.Core.csproj -c Release -o ./nupkg +dotnet pack src/PptMcp.McpServer/PptMcp.McpServer.csproj -c Release -o ./nupkg +dotnet pack src/PptMcp.CLI/PptMcp.CLI.csproj -c Release -o ./nupkg ``` ### Test Local Installation ```powershell # Install .NET tool from local package -dotnet tool install --global Sbroenne.ExcelMcp.CLI --add-source ./nupkg --version 1.0.0 +dotnet tool install --global PptMcp.CLI --add-source ./nupkg --version 1.0.0 # Test the tool -excelcli --help +pptcli --help # Uninstall -dotnet tool uninstall --global Sbroenne.ExcelMcp.CLI +dotnet tool uninstall --global PptMcp.CLI ``` ### Validate Package Contents ```powershell # Extract package (NuGet packages are ZIP files) -unzip -l ./nupkg/Sbroenne.ExcelMcp.Core.1.0.0.nupkg +unzip -l ./nupkg/PptMcp.Core.1.0.0.nupkg # Verify: # - README.md is included @@ -300,7 +300,7 @@ unzip -l ./nupkg/Sbroenne.ExcelMcp.Core.1.0.0.nupkg **Wait Time**: NuGet.org indexing takes 5-10 minutes after publishing. **Check Workflow Logs**: -1. Go to: https://github.com/sbroenne/mcp-server-excel/actions +1. Go to: https://github.com/trsdn/mcp-server-ppt/actions 2. Find the release workflow run 3. Check "Publish to NuGet.org" step for errors @@ -311,8 +311,8 @@ unzip -l ./nupkg/Sbroenne.ExcelMcp.Core.1.0.0.nupkg **Solution**: 1. Verify package exists on NuGet.org 2. Check trusted publisher configuration matches exactly: - - Owner: `sbroenne` - - Repository: `mcp-server-excel` + - Owner: `trsdn` + - Repository: `mcp-server-ppt` - Workflow: `release-[package].yml` (exact filename) 3. Ensure `NUGET_USER` secret is set correctly 4. Verify workflow has `id-token: write` permission @@ -406,7 +406,7 @@ If you rename a workflow file: ### GitHub Actions -- **Workflow Runs**: https://github.com/sbroenne/mcp-server-excel/actions +- **Workflow Runs**: https://github.com/trsdn/mcp-server-ppt/actions - Each release workflow creates: - NuGet package upload - GitHub release with notes @@ -414,10 +414,10 @@ If you rename a workflow file: ### NuGet.org Package Pages -- **ComInterop**: https://www.nuget.org/packages/Sbroenne.ExcelMcp.ComInterop -- **Core**: https://www.nuget.org/packages/Sbroenne.ExcelMcp.Core -- **MCP Server**: https://www.nuget.org/packages/Sbroenne.ExcelMcp.McpServer -- **CLI**: https://www.nuget.org/packages/Sbroenne.ExcelMcp.CLI +- **ComInterop**: https://www.nuget.org/packages/PptMcp.ComInterop +- **Core**: https://www.nuget.org/packages/PptMcp.Core +- **MCP Server**: https://www.nuget.org/packages/PptMcp.McpServer +- **CLI**: https://www.nuget.org/packages/PptMcp.CLI ### Download Statistics @@ -442,7 +442,7 @@ For issues with NuGet publishing: 1. Check this guide's troubleshooting section 2. Review GitHub Actions workflow logs 3. Verify NuGet.org trusted publisher configuration -4. Open an issue at: https://github.com/sbroenne/mcp-server-excel/issues +4. Open an issue at: https://github.com/trsdn/mcp-server-ppt/issues --- diff --git a/docs/PIA-COVERAGE.md b/docs/PIA-COVERAGE.md deleted file mode 100644 index 486683dc..00000000 --- a/docs/PIA-COVERAGE.md +++ /dev/null @@ -1,163 +0,0 @@ -# PIA Coverage Guide - -This document tracks the status of `Microsoft.Office.Interop.Excel` (v16) type coverage across ExcelMcp, explains why some casts remain `dynamic`, and documents the verified methodology for checking PIA coverage. - ---- - -## TL;DR — Current Status - -| Area | Status | -|------|--------| -| Core Excel types (`Workbook`, `Worksheet`, `Range`, etc.) | ✅ Fully typed | -| Collections (`Sheets`, `Names`, `ListObjects`, etc.) | ✅ Fully typed | -| Power Query (`Workbook.Queries`, `WorkbookQuery`) | ✅ Fully typed | -| DataModel (`Model`, `ModelMeasures`, `ModelTables`, etc.) | ✅ Migrated — all model types use PIA | -| Connection sub-types (`OLEDBConnection`, `ODBCConnection`, `TextConnection`) | ✅ Migrated — callers use typed WorkbookConnection | -| `ModelTableColumn.IsCalculatedColumn` | ❌ True PIA gap — property missing from v16 PIA | -| `ModelMeasure.FormatInformation` | ⚠️ Returns `object` in PIA; cast to `dynamic` for property probing | -| VBA (`VBProject`, `VBComponents`) | ❌ True PIA gap — in `Microsoft.Vbe.Interop` only | -| `AutomationSecurity` | ❌ True PIA gap — in `Microsoft.Office.Core` (office.dll); use `((dynamic)(object))` cast | -| WebConnection | ❌ True PIA gap — not in Excel PIA | -| ADO types (ADODB.Connection, Recordset, Fields) | ❌ True PIA gap — in ADODB, not Excel PIA | - ---- - -## True PIA Gaps - -These APIs are **not** in `Microsoft.Office.Interop.Excel` and will remain `dynamic` permanently. - -### VBProject / VBComponents / VBComponent - -- **Location in COM**: `Microsoft.Vbe.Interop` (separate DLL — `vbe7.dll` / `VBA7.dll`) -- **Why not available**: No .NET 5+ compatible NuGet package exists. `ThammimTech.Microsoft.Vbe.Interop` targets .NET Framework only. The official `Microsoft.Vbe.Interop` NuGet package is unsigned and unmaintained. -- **Workaround**: `((dynamic)ctx.Book).VBProject` — COM late binding to the VBE object model -- **Affected files**: `VbaCommands.Lifecycle.cs`, `VbaCommands.Operations.cs` - -### MsoAutomationSecurity (= 3) - -- **Location in COM**: `Microsoft.Office.Core` (office.dll / `Microsoft.Office.Interop.Word` / `Microsoft.Office.Interop.PowerPoint` host DLLs) -- **Why not available**: The `office.dll` shared types are injected via `[assembly: PrimaryInteropAssembly]` into host Office PIAs. They are not directly available as a standalone typed constant in the Excel PIA. -- **Workaround**: Use the literal integer value `3` (= `msoAutomationSecurityForceDisable`) via `((dynamic)(object)tempExcel).AutomationSecurity = 3;` -- **CRITICAL — cast to `(object)` first**: Casting a typed `Excel.Application` directly to `dynamic` retains COM type metadata; the DLR then tries to load `office.dll` to resolve `MsoAutomationSecurity`, causing a `FileNotFoundException` crash (`office, Version=16.0.0.0`). Casting to `(object)` first erases the static type and forces pure IDispatch binding, which never loads `office.dll`. -- **Do NOT add a `` to office.dll**: A GAC hint path is version-specific and machine-specific (15.0 vs 16.0 mismatch causes the same crash). `EmbedInteropTypes=true` + `(object)` cast is sufficient. -- **Affected files**: `ExcelBatch.cs` - -### WebConnection - -- **Simply not in the Excel PIA**: `WorkbookConnection.WebConnection` is not exposed in `Microsoft.Office.Interop.Excel` v16. Only `OLEDBConnection`, `ODBCConnection`, and `TextConnection` have typed sub-connection properties. -- **Affected files**: `ConnectionCommands.cs` (`GetTypedSubConnection` method) - ---- - -## TODO — Types IN the PIA, Migration Pending - -These were incorrectly left as `dynamic` during the initial PIA migration due to a false negative from binary string search on the assembly. All have been confirmed by compile test. - -### Power Query — `Excel.Queries` / `Excel.WorkbookQuery` - -- **Compile test result**: `Excel.Queries q = book.Queries;` compiles cleanly with the v16 PIA -- **WorkbookQuery properties available**: `.Name`, `.Formula` -- **Why left as dynamic**: Binary inspection using `Encoding.Unicode` (incorrect) reported these types as absent. The true method (reflection / compile test) was not used. -- **Affected files**: `PowerQueryCommands.Create.cs`, `Evaluate.cs`, `Lifecycle.cs` (2x), `Refresh.cs`, `Rename.cs`, `Update.cs`, `LoadTo.cs`, `View.cs`, `ComUtilities.cs` -- **Migration effort**: Medium — replace 9 occurrences of `((dynamic)ctx.Book).Queries` + update `ComUtilities.FindQuery()` signature - -### DataModel — `Excel.Model`, `Excel.ModelMeasures`, `Excel.ModelTables`, etc. - -- **Types confirmed in PIA**: - - `Excel.Model` (`workbook.Model`) - - `Excel.ModelMeasures`, `Excel.ModelMeasure` - - `Excel.ModelTables`, `Excel.ModelTable` - - `Excel.ModelRelationships`, `Excel.ModelRelationship` - - `Excel.ModelTableColumns`, `Excel.ModelTableColumn` - - Format types: `ModelFormatGeneral`, `ModelFormatCurrency`, `ModelFormatDecimalNumber`, `ModelFormatDate`, `ModelFormatBoolean`, `ModelFormatPercentageNumber`, `ModelFormatWholeNumber`, `ModelFormatScientificNumber` -- **Affected files**: `DataModelCommands.Helpers.cs` and all DataModel command files -- **Migration effort**: High — `DataModelCommands.Helpers.cs` is entirely `dynamic` internally - -### Connection Sub-Types — `Excel.OLEDBConnection`, `Excel.ODBCConnection`, `Excel.TextConnection` - -- **Compile test result**: All three sub-connection types compile cleanly -- **Note**: The collection is `Excel.Connections` (NOT `WorkbookConnections`) -- **Affected files**: `ConnectionCommands.cs` (`GetTypedSubConnection` helper) -- **Migration effort**: Low — one helper method - -### ComUtilities Helpers — Return Types - -- `FindQuery(dynamic workbook, ...)` — both parameter and return type can be typed once Queries migration is done -- `FindConnection(dynamic workbook, ...)` — return type can be `Excel.WorkbookConnection?` -- `FindSheet(...)` — return type can be `Excel.Worksheet?` -- `FindName(...)` — return type can be `Excel.Name?` -- `ResolveRange()` in `RangeHelpers.cs` — return type can be `Excel.Range?` - ---- - -## Verified Methodology for Checking PIA Coverage - -### THE CORRECT METHOD: Compile test - -Create a probe project **outside the repo** (to avoid Central Package Management interference): - -```powershell -mkdir D:\temp\pia-probe -cd D:\temp\pia-probe -dotnet new console -dotnet add package Microsoft.Office.Interop.Excel --version 15.0.4795.1000 -``` - -Edit `Program.cs`: -```csharp -using Excel = Microsoft.Office.Interop.Excel; - -// Test: is this type in the PIA? -Excel.Queries q = default!; // Compiles → in PIA -Excel.WorkbookQuery wq = default!; // Compiles → in PIA -Excel.OLEDBConnection oledb = default!; // Compiles → in PIA -// etc. -``` - -Then: -```powershell -dotnet build -``` - -If it compiles → the type is in the PIA. If CS0246 → it's not. - -### WRONG: Binary string search - -```powershell -# ❌ This is WRONG — produces false negatives for ALL type names -[System.IO.File]::ReadAllText($dll, [System.Text.Encoding]::Unicode) -# .NET assemblies are binary; reading as UTF-16LE produces garbage -``` - -### OK (but less reliable): Reflection - -```powershell -$asm = [System.Reflection.Assembly]::LoadFrom($dllPath) -try { $types = $asm.GetTypes() } catch [System.Reflection.ReflectionTypeLoadException] { $types = $_.Exception.Types | Where-Object { $_ -ne $null } } -$types | Where-Object { $_.Name -like "*Query*" } -``` - -Reflection works but may miss embedded types or forwarded types. Compile test is always definitive. - ---- - -## Pre-Commit Enforcement - -The `scripts/check-dynamic-casts.ps1` script enforces that every `((dynamic))` cast has a justification comment on the preceding line. - -Valid comment prefixes: -- `// PIA gap: ...` — Type is a true gap (not in v16 PIA) -- `// TODO: ...` — Type IS in PIA but migration is pending -- `// Reason: ...` — Other documented reason - -The check is run automatically by `scripts/pre-commit.ps1`. - ---- - -## How This Happened (Root Cause) - -During the original PIA migration, coverage was checked using binary string search with `[System.Text.Encoding]::Unicode` on the PIA DLL. This method reads the binary assembly as UTF-16LE text, which produces garbage. All string searches return false negatives — every searched type name was reported as "NOT FOUND" even when present. - -This caused 9 Power Query, 8 DataModel, and 3 Connection sub-type APIs to be incorrectly classified as "PIA gaps" when they are in fact available in the v16 PIA. - -**Prevention**: Always use a compile test (see above). This file and the `check-dynamic-casts.ps1` pre-commit check prevent silent regression. diff --git a/docs/RELEASE-STRATEGY.md b/docs/RELEASE-STRATEGY.md index e64e8636..1ada7d01 100644 --- a/docs/RELEASE-STRATEGY.md +++ b/docs/RELEASE-STRATEGY.md @@ -1,10 +1,10 @@ -# ExcelMcp Release Strategy +# PptMcp Release Strategy -This document outlines the unified release process for all ExcelMcp components. +This document outlines the unified release process for all PptMcp components. ## Overview -All ExcelMcp components are released together with a single version tag: +All PptMcp components are released together with a single version tag: | Component | Distribution | Description | |-----------|--------------|-------------| @@ -24,7 +24,7 @@ All ExcelMcp components are released together with a single version tag: When you run the release workflow: 1. **CLI** → Built as dependency (artifact shared with MCP Server job) -2. **MCP Server** → Unified NuGet (`Sbroenne.ExcelMcp.McpServer` — includes CLI) + ZIP +2. **MCP Server** → Unified NuGet (`PptMcp.McpServer` — includes CLI) + ZIP 3. **VS Code Extension** → Self-contained VSIX (bundles both exes + skills) → VS Code Marketplace 4. **MCPB** → Claude Desktop bundle (`.mcpb` file) 5. **Agent Skills** → ZIP package for AI coding assistants @@ -35,11 +35,11 @@ When you run the release workflow: | Artifact | Format | Distribution | |----------|--------|--------------| -| `ExcelMcp-MCP-Server-{version}-windows.zip` | ZIP | GitHub Release | -| `excelmcp-{version}.vsix` | VSIX | GitHub Release + VS Code Marketplace (~68-70 MB, self-contained) | -| `excel-mcp-{version}.mcpb` | MCPB | GitHub Release | -| `excel-skills-v{version}.zip` | ZIP | GitHub Release | -| `Sbroenne.ExcelMcp.McpServer.{version}.nupkg` | NuGet | NuGet.org (unified — includes CLI) | +| `PptMcp-MCP-Server-{version}-windows.zip` | ZIP | GitHub Release | +| `PptMcp-{version}.vsix` | VSIX | GitHub Release + VS Code Marketplace (~68-70 MB, self-contained) | +| `ppt-mcp-{version}.mcpb` | MCPB | GitHub Release | +| `ppt-skills-v{version}.zip` | ZIP | GitHub Release | +| `PptMcp.McpServer.{version}.nupkg` | NuGet | NuGet.org (unified — includes CLI) | > **Note:** Separate CLI ZIP and CLI NuGet package are no longer produced. The CLI is bundled in the unified MCP Server NuGet package and in the VS Code extension. @@ -178,7 +178,6 @@ Configure these GitHub repository secrets: |--------|---------| | `NUGET_USER` | NuGet.org username (for OIDC trusted publishing) | | `VSCE_TOKEN` | VS Code Marketplace PAT | -| `APPINSIGHTS_CONNECTION_STRING` | Application Insights (optional telemetry) | > **Note:** NuGet uses OIDC trusted publishing (no API key needed). The `NUGET_USER` is just the NuGet.org profile name for OIDC token exchange. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index d554f7bd..5529f8c6 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -2,7 +2,7 @@ ## Supported Versions -We currently support the following versions of ExcelMcp with security updates: +We currently support the following versions of PptMcp with security updates: | Version | Supported | | ------- | ------------------ | @@ -10,7 +10,7 @@ We currently support the following versions of ExcelMcp with security updates: ## Reporting a Vulnerability -We take security seriously. If you discover a security vulnerability in Sbroenne.ExcelMcp, please report it responsibly. +We take security seriously. If you discover a security vulnerability in PptMcp, please report it responsibly. ### How to Report @@ -33,7 +33,7 @@ We take security seriously. If you discover a security vulnerability in Sbroenne ### Enhanced Security Features (Latest Version) -ExcelMcp implements comprehensive security measures: +PptMcp implements comprehensive security measures: - **Input Validation**: All file paths validated with length limits (32767 chars) and extension restrictions - **File Size Limits**: 1GB maximum file size to prevent DoS attacks @@ -42,23 +42,23 @@ ExcelMcp implements comprehensive security measures: - **Code Analysis**: Enhanced security rules enforced (CA2100, CA3003, CA3006, etc.) - **Quality Enforcement**: All warnings treated as errors for robust code -### Excel COM Automation +### PowerPoint COM Automation -ExcelMcp uses Excel COM automation with security safeguards: +PptMcp uses PowerPoint COM automation with security safeguards: -- **Macro Execution**: ExcelMcp can execute VBA macros when using script-run command -- **VBA Trust**: VBA operations require "Trust access to the VBA project object model" to be manually enabled in Excel settings (one-time setup) -- **File Validation**: Strict file extension validation (.xlsx, .xlsm, .xls only) -- **File Access**: ExcelMcp requires read/write access to Excel files with size validation +- **Macro Execution**: PptMcp can execute VBA macros when using script-run command +- **VBA Trust**: VBA operations require "Trust access to the VBA project object model" to be manually enabled in PowerPoint settings (one-time setup) +- **File Validation**: Strict file extension validation (.pptx, .pptm, .ppt only) +- **File Access**: PptMcp requires read/write access to PowerPoint files with size validation - **Process Isolation**: Each command runs in a separate process that terminates after completion -- **Excel Instance**: Creates temporary Excel instances that are properly cleaned up +- **PowerPoint Instance**: Creates temporary PowerPoint instances that are properly cleaned up - **Input Sanitization**: All arguments validated for length and content ### Power Query Privacy Levels -ExcelMcp implements security-first privacy level handling: +PptMcp implements security-first privacy level handling: -- **Explicit Consent**: Privacy levels must be specified explicitly via `--privacy-level` parameter or `EXCEL_DEFAULT_PRIVACY_LEVEL` environment variable +- **Explicit Consent**: Privacy levels must be specified explicitly via `--privacy-level` parameter or `PPT_DEFAULT_PRIVACY_LEVEL` environment variable - **No Auto-Application**: Privacy levels are never applied automatically without user consent - **Privacy Detection**: Analyzes existing queries to recommend appropriate privacy levels - **Clear Guidance**: Provides detailed explanations of privacy level implications @@ -67,32 +67,32 @@ ExcelMcp implements security-first privacy level handling: ### VBA Security Considerations - **Macro Content**: VBA scripts imported via script-import will be executed when called -- **Manual Trust Setup**: VBA trust must be enabled manually through Excel's Trust Center settings (never modified automatically by ExcelMcp) -- **File Format**: Only .xlsm files can contain and execute VBA code +- **Manual Trust Setup**: VBA trust must be enabled manually through PowerPoint's Trust Center settings (never modified automatically by PptMcp) +- **File Format**: Only .pptm files can contain and execute VBA code - **Code Injection**: Always validate VBA source files before importing -- **User Control**: ExcelMcp never modifies registry settings or security configurations automatically +- **User Control**: PptMcp never modifies registry settings or security configurations automatically ### Best Practices for Users -1. **File Validation**: Only run ExcelMcp on trusted Excel files +1. **File Validation**: Only run PptMcp on trusted PowerPoint files 2. **VBA Source Control**: Validate VBA code files before importing with script-import 3. **Network Files**: Be cautious when processing files from network locations -4. **Permissions**: Run ExcelMcp with minimal necessary permissions -5. **Backup**: Always backup important Excel files before processing -6. **VBA Trust**: Only enable VBA trust in Excel settings on systems where it's needed (manual one-time setup) +4. **Permissions**: Run PptMcp with minimal necessary permissions +5. **Backup**: Always backup important PowerPoint files before processing +6. **VBA Trust**: Only enable VBA trust in PowerPoint settings on systems where it's needed (manual one-time setup) 7. **Code Review**: Review VBA scripts before execution, especially from external sources 8. **Privacy Levels**: Choose appropriate Power Query privacy levels based on data sensitivity (Private for sensitive data, Organizational for internal data, Public for public APIs) -9. **Environment Variables**: Use `EXCEL_DEFAULT_PRIVACY_LEVEL` environment variable for consistent automation security +9. **Environment Variables**: Use `PPT_DEFAULT_PRIVACY_LEVEL` environment variable for consistent automation security ### Known Limitations -- **Windows Only**: ExcelMcp only works on Windows with Excel installed -- **COM Dependencies**: Relies on Excel COM objects which may have their own security considerations -- **File System Access**: Requires appropriate file system permissions for Excel file access +- **Windows Only**: PptMcp only works on Windows with PowerPoint installed +- **COM Dependencies**: Relies on PowerPoint COM objects which may have their own security considerations +- **File System Access**: Requires appropriate file system permissions for PowerPoint file access ## Dependency Security -ExcelMcp has minimal dependencies to reduce attack surface: +PptMcp has minimal dependencies to reduce attack surface: - **.NET 10**: Microsoft-maintained runtime with regular security updates - **Spectre.Console**: Well-maintained library for console output @@ -101,7 +101,7 @@ ExcelMcp has minimal dependencies to reduce attack surface: ## Version Updates - Security patches will be released as soon as possible -- Users are encouraged to keep ExcelMcp updated to the latest version +- Users are encouraged to keep PptMcp updated to the latest version - Breaking changes will be clearly documented in release notes ## Contact diff --git a/docs/TEST-NAMING-STANDARD.md b/docs/TEST-NAMING-STANDARD.md index dc2f6f42..9d9255a2 100644 --- a/docs/TEST-NAMING-STANDARD.md +++ b/docs/TEST-NAMING-STANDARD.md @@ -1,4 +1,4 @@ -# Test Naming Standard - ExcelMcp +# Test Naming Standard - PptMcp ## Overview @@ -31,7 +31,7 @@ MethodName_StateUnderTest_ExpectedBehavior ```csharp // CRUD Operations [Fact] -public async Task List_EmptyWorkbook_ReturnsEmptyList() +public async Task List_EmptyPresentation_ReturnsEmptyList() [Fact] public async Task Create_ValidName_ReturnsSuccess() @@ -85,7 +85,7 @@ public async Task TestCreate() // ❌ No state or expectation // Method suffix included [Fact] -public async Task ListAsync_EmptyWorkbook_ReturnsEmptyList() // ❌ Remove "Async" +public async Task ListAsync_EmptyPresentation_ReturnsEmptyList() // ❌ Remove "Async" ``` ## Pattern Catalog @@ -94,7 +94,7 @@ public async Task ListAsync_EmptyWorkbook_ReturnsEmptyList() // ❌ Remove "Asy | Operation | Pattern | Example | |-----------|---------|---------| -| **List** | `List__Returns` | `List_EmptyWorkbook_ReturnsEmptyList` | +| **List** | `List__Returns` | `List_EmptyPresentation_ReturnsEmptyList` | | **Create** | `Create__` | `Create_ValidName_ReturnsSuccess` | | **View/Get** | `View__Returns` | `View_ExistingTable_ReturnsMetadata` | | **Update** | `Update__` | `Update_ExistingQuery_ReturnsSuccess` | @@ -121,9 +121,9 @@ public async Task ListAsync_EmptyWorkbook_ReturnsEmptyList() // ❌ Remove "Asy | State | Pattern | Example | |-------|---------|---------| -| **Empty** | `_EmptyWorkbook_` | `List_EmptyWorkbook_ReturnsEmptyList` | -| **With Data** | `_WorkbookWithData_` | `Refresh_WorkbookWithData_UpdatesValues` | -| **Multiple Items** | `_MultipleItems_` | `Delete_WorkbookWithMultipleSheets_RemovesTargetOnly` | +| **Empty** | `_EmptyPresentation_` | `List_EmptyPresentation_ReturnsEmptyList` | +| **With Data** | `_PresentationWithData_` | `Refresh_PresentationWithData_UpdatesValues` | +| **Multiple Items** | `_MultipleItems_` | `Delete_PresentationWithMultipleSlides_RemovesTargetOnly` | ## Feature-Specific Guidelines @@ -134,7 +134,7 @@ public async Task ListAsync_EmptyWorkbook_ReturnsEmptyList() // ❌ Remove "Asy ### PowerQuery Tests - **Fixture-based**: `__` or `__` - Example: `View_BasicQuery_ReturnsMCode` -- Example: `Import_NewQuery_AddsToWorkbook` +- Example: `Import_NewQuery_AddsToPresentation` ### VBA Tests - **Trust required**: `_WithTrustEnabled_` @@ -172,8 +172,8 @@ Before committing test code, verify each test name: List_WithValidFile_ReturnsSuccessResult // ✅ AFTER -List_EmptyWorkbook_ReturnsEmptyList -List_WorkbookWithQueries_ReturnsList +List_EmptyPresentation_ReturnsEmptyList +List_PresentationWithQueries_ReturnsList ``` ### 2. Missing Expected Behavior @@ -200,10 +200,10 @@ Import_ValidMCode_ReturnsSuccess ```csharp // ❌ BEFORE -ListAsync_EmptyWorkbook_ReturnsEmptyList +ListAsync_EmptyPresentation_ReturnsEmptyList // ✅ AFTER -List_EmptyWorkbook_ReturnsEmptyList +List_EmptyPresentation_ReturnsEmptyList ``` ### 5. Workflow Tests Not Clear diff --git a/docs/TIMEOUT-IMPLEMENTATION-GUIDE.md b/docs/TIMEOUT-IMPLEMENTATION-GUIDE.md index 33a03c8f..053fdf10 100644 --- a/docs/TIMEOUT-IMPLEMENTATION-GUIDE.md +++ b/docs/TIMEOUT-IMPLEMENTATION-GUIDE.md @@ -4,7 +4,7 @@ ## Overview -Excel batch operations now have timeout protection to prevent indefinite hangs when Excel becomes unresponsive (modal dialogs, data source issues, COM deadlocks). +PowerPoint batch operations now have timeout protection to prevent indefinite hangs when PowerPoint becomes unresponsive (modal dialogs, data source issues, COM deadlocks). **Key Features:** - Default 2-minute timeout for all operations @@ -17,11 +17,11 @@ Excel batch operations now have timeout protection to prevent indefinite hangs w ## Core Implementation -### Constants (ExcelBatch.cs) +### Constants (PptBatch.cs) ```csharp /// -/// Default timeout for Excel operations (2 minutes) +/// Default timeout for PowerPoint operations (2 minutes) /// private static readonly TimeSpan DefaultOperationTimeout = TimeSpan.FromMinutes(2); @@ -31,16 +31,16 @@ private static readonly TimeSpan DefaultOperationTimeout = TimeSpan.FromMinutes( private static readonly TimeSpan MaxOperationTimeout = TimeSpan.FromMinutes(5); ``` -### IExcelBatch Interface +### IPptBatch Interface ```csharp Task Execute( - Func operation, + Func operation, CancellationToken cancellationToken = default, TimeSpan? timeout = null); // ← NEW: Optional timeout parameter Task ExecuteAsync( - Func> operation, + Func> operation, CancellationToken cancellationToken = default, TimeSpan? timeout = null); // ← NEW: Optional timeout parameter ``` @@ -69,13 +69,13 @@ catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && ! var duration = DateTime.UtcNow - startTime; var usedMaxTimeout = effectiveTimeout >= MaxOperationTimeout; - Console.Error.WriteLine($"[EXCEL-BATCH] TIMEOUT after {duration.TotalSeconds:F1}s (limit: {effectiveTimeout.TotalMinutes:F1}min, max: {usedMaxTimeout})"); + Console.Error.WriteLine($"[PPT-BATCH] TIMEOUT after {duration.TotalSeconds:F1}s (limit: {effectiveTimeout.TotalMinutes:F1}min, max: {usedMaxTimeout})"); var message = usedMaxTimeout - ? $"Excel operation exceeded maximum timeout of {MaxOperationTimeout.TotalMinutes} minutes (actual: {duration.TotalMinutes:F1} min). " + - "This indicates Excel is hung, unresponsive, or the operation is too complex. " + - "Check if Excel is showing a dialog or consider breaking the operation into smaller steps." - : $"Excel operation timed out after {effectiveTimeout.TotalMinutes} minutes (actual: {duration.TotalMinutes:F1} min). " + + ? $"PowerPoint operation exceeded maximum timeout of {MaxOperationTimeout.TotalMinutes} minutes (actual: {duration.TotalMinutes:F1} min). " + + "This indicates PowerPoint is hung, unresponsive, or the operation is too complex. " + + "Check if PowerPoint is showing a dialog or consider breaking the operation into smaller steps." + : $"PowerPoint operation timed out after {effectiveTimeout.TotalMinutes} minutes (actual: {duration.TotalMinutes:F1} min). " + $"For large datasets or complex operations, more time may be needed (maximum: {MaxOperationTimeout.TotalMinutes} min)."; throw new TimeoutException(message); @@ -115,7 +115,7 @@ For operations that typically take longer (refresh, data loading), Core commands ```csharp // In PowerQueryCommands.cs -public async Task RefreshAsync(IExcelBatch batch, string queryName) +public async Task RefreshAsync(IPptBatch batch, string queryName) { // Heavy operation: request extended timeout (5 minutes) return await batch.Execute( @@ -133,18 +133,18 @@ public async Task RefreshAsync(IExcelBatch batch, strin MCP tools should catch `TimeoutException` and enrich with operation-specific guidance: ```csharp -// In ExcelPowerQueryTool.cs +// In PptPowerQueryTool.cs private static async Task RefreshPowerQueryAsync(...) { try { - var result = await ExcelToolsBase.WithBatchAsync( + var result = await PptToolsBase.WithBatchAsync( batchId, - excelPath, + presentationPath, save: true, async (batch) => await commands.RefreshAsync(batch, queryName)); - return JsonSerializer.Serialize(result, ExcelToolsBase.JsonOptions); + return JsonSerializer.Serialize(result, PptToolsBase.JsonOptions); } catch (TimeoutException ex) { @@ -154,15 +154,15 @@ private static async Task RefreshPowerQueryAsync(...) Success = false, ErrorMessage = ex.Message, QueryName = queryName, - FilePath = excelPath, + FilePath = presentationPath, // ✨ Add LLM guidance SuggestedNextActions = new List { - "Check if Excel is showing a 'Privacy Level' dialog or other modal dialogs", + "Check if PowerPoint is showing a 'Privacy Level' dialog or other modal dialogs", "Verify the data source is accessible (network connection, database availability)", "Consider breaking query into smaller steps if processing large datasets", - "Use batch mode (begin_excel_batch) if not already using it" + "Use batch mode (begin_ppt_batch) if not already using it" }, OperationContext = new Dictionary @@ -176,11 +176,11 @@ private static async Task RefreshPowerQueryAsync(...) IsRetryable = !ex.Message.Contains("maximum timeout"), // Don't retry max timeout RetryGuidance = ex.Message.Contains("maximum timeout") - ? "Operation reached maximum timeout - do not retry. Check for Excel dialogs or break into smaller operations." + ? "Operation reached maximum timeout - do not retry. Check for PowerPoint dialogs or break into smaller operations." : "Operation can be retried with longer timeout (up to 5 minutes) if data source is slow." }; - return JsonSerializer.Serialize(result, ExcelToolsBase.JsonOptions); + return JsonSerializer.Serialize(result, PptToolsBase.JsonOptions); } } ``` @@ -191,7 +191,7 @@ For quick operations (list, get, create), use default 2-minute timeout: ```csharp // In PowerQueryCommands.cs -public async Task ListAsync(IExcelBatch batch) +public async Task ListAsync(IPptBatch batch) { // Light operation: use default timeout (no parameter needed) return await batch.Execute((ctx, ct) => @@ -224,15 +224,15 @@ public async Task ListAsync(IExcelBatch batch) Operations automatically log progress to stderr (visible in MCP clients): ``` -[EXCEL-BATCH] Starting operation (timeout: 2.0min) -[EXCEL-BATCH] Completed in 3.5s +[PPT-BATCH] Starting operation (timeout: 2.0min) +[PPT-BATCH] Completed in 3.5s ``` Or on timeout: ``` -[EXCEL-BATCH] Starting operation (timeout: 5.0min) -[EXCEL-BATCH] TIMEOUT after 300.2s (limit: 5.0min, max: true) +[PPT-BATCH] Starting operation (timeout: 5.0min) +[PPT-BATCH] TIMEOUT after 300.2s (limit: 5.0min, max: true) ``` --- @@ -268,11 +268,11 @@ Or on timeout: ```csharp // Core Command (PowerQueryCommands.Refresh.cs) -public async Task RefreshAsync(IExcelBatch batch, string queryName) +public async Task RefreshAsync(IPptBatch batch, string queryName) { var result = new PowerQueryRefreshResult { - FilePath = batch.WorkbookPath, + FilePath = batch.PresentationPath, QueryName = queryName, RefreshTime = DateTime.Now }; @@ -288,10 +288,10 @@ public async Task RefreshAsync(IExcelBatch batch, strin ); } -// MCP Tool (ExcelPowerQueryTool.cs) +// MCP Tool (PptPowerQueryTool.cs) private static async Task RefreshPowerQueryAsync( PowerQueryCommands commands, - string excelPath, + string presentationPath, string? queryName, string? batchId) { @@ -300,13 +300,13 @@ private static async Task RefreshPowerQueryAsync( try { - var result = await ExcelToolsBase.WithBatchAsync( + var result = await PptToolsBase.WithBatchAsync( batchId, - excelPath, + presentationPath, save: true, async (batch) => await commands.RefreshAsync(batch, queryName)); - return JsonSerializer.Serialize(result, ExcelToolsBase.JsonOptions); + return JsonSerializer.Serialize(result, PptToolsBase.JsonOptions); } catch (TimeoutException ex) { @@ -315,15 +315,15 @@ private static async Task RefreshPowerQueryAsync( Success = false, ErrorMessage = ex.Message, QueryName = queryName, - FilePath = excelPath, + FilePath = presentationPath, RefreshTime = DateTime.Now, SuggestedNextActions = new List { - "Check if Excel is showing a 'Privacy Level' dialog", + "Check if PowerPoint is showing a 'Privacy Level' dialog", "Verify the data source is accessible", "Consider using smaller data ranges if processing large datasets", - "Use batch mode (begin_excel_batch) if not already" + "Use batch mode (begin_ppt_batch) if not already" }, OperationContext = new Dictionary @@ -341,7 +341,7 @@ private static async Task RefreshPowerQueryAsync( : "Retry with same timeout acceptable if transient issue suspected." }; - return JsonSerializer.Serialize(result, ExcelToolsBase.JsonOptions); + return JsonSerializer.Serialize(result, PptToolsBase.JsonOptions); } } ``` @@ -364,7 +364,7 @@ public void Execute_TimeoutClamping_LimitsToMax() } ``` -### Integration Tests (Requires Excel) +### Integration Tests (Requires PowerPoint) Test actual timeout behavior: @@ -372,7 +372,7 @@ Test actual timeout behavior: [Fact] public async Task Execute_ExceedsTimeout_ThrowsTimeoutException() { - await using var batch = await ExcelSession.BeginBatchAsync(testFile); + await using var batch = await PptSession.BeginBatchAsync(testFile); // Operation that takes 3 seconds with 1-second timeout await Assert.ThrowsAsync(async () => @@ -392,13 +392,13 @@ public async Task Execute_ExceedsTimeout_ThrowsTimeoutException() ### Symptom: All operations timing out -**Likely Cause:** Excel showing modal dialog (Privacy Level, Credentials, etc.) +**Likely Cause:** PowerPoint showing modal dialog (Privacy Level, Credentials, etc.) **Solution:** -1. Check if Excel process is visible (should be hidden background process) -2. Kill all Excel processes: `taskkill /F /IM excel.exe` +1. Check if PowerPoint process is visible (should be hidden background process) +2. Kill all PowerPoint processes: `taskkill /F /IM powerpnt.exe` 3. Re-run operation -4. If persistent, manually open Excel, configure privacy levels, save workbook +4. If persistent, manually open PowerPoint, configure privacy levels, save presentation ### Symptom: Max timeout reached frequently @@ -423,7 +423,7 @@ public async Task Execute_ExceedsTimeout_ThrowsTimeoutException() ### Potential Improvements -1. **Configurable Max Timeout**: Allow per-workbook or per-session max timeout override (currently hardcoded 5 min) +1. **Configurable Max Timeout**: Allow per-presentation or per-session max timeout override (currently hardcoded 5 min) 2. **Progress Callbacks**: Provide progress updates during long operations 3. **Timeout Metrics**: Collect stats on timeout frequency to identify problematic operations 4. **Adaptive Timeout**: Automatically increase timeout for operations that consistently hit limit @@ -432,7 +432,7 @@ public async Task Execute_ExceedsTimeout_ThrowsTimeoutException() ## Related Documentation -- [Excel COM Interop Patterns](.github/instructions/excel-com-interop.instructions.md) +- [PowerPoint COM Interop Patterns](.github/instructions/powerpoint-com-interop.instructions.md) - [MCP Server Guide](.github/instructions/mcp-server-guide.instructions.md) - [Testing Strategy](.github/instructions/testing-strategy.instructions.md) diff --git a/docs/VERSION-CHECKING.md b/docs/VERSION-CHECKING.md index 3e495a85..49beb229 100644 --- a/docs/VERSION-CHECKING.md +++ b/docs/VERSION-CHECKING.md @@ -1,10 +1,10 @@ # Version Checking and Update Notifications -This document describes how version checking and update notifications work in ExcelMcp. +This document describes how version checking and update notifications work in PptMcp. ## Overview -ExcelMcp provides version checking in two contexts: +PptMcp provides version checking in two contexts: 1. **CLI Tool** - Manual version check and automatic service startup notification 2. **MCP Server** - Protocol-level version negotiation (handled by MCP SDK) @@ -16,7 +16,7 @@ ExcelMcp provides version checking in two contexts: Users can check for updates at any time using the `--version` flag: ```powershell -excelcli --version +pptcli --version ``` This command: @@ -28,13 +28,10 @@ This command: **Example output when update is available:** ``` ⚠ Update available: 1.0.0 → 1.1.0 -Run: dotnet tool update --global Sbroenne.ExcelMcp.McpServer -Release notes: https://github.com/sbroenne/mcp-server-excel/releases/latest -``` - -### Automatic Service Notification +Run: dotnet tool update --global PptMcp.McpServer +Release notes: https://github.com/trsdn/mcp-server-ppt/releases/latest -When the ExcelMCP Service starts, it automatically checks for updates in the background: +When the PptMcp Service starts, it automatically checks for updates in the background: 1. **Timing**: Check occurs 5 seconds after service startup 2. **Non-blocking**: Version check runs asynchronously and never blocks service operations @@ -42,7 +39,7 @@ When the ExcelMCP Service starts, it automatically checks for updates in the bac 4. **Windows notification**: If an update is available, a system tray notification appears **Notification Details:** -- **Title**: "Excel MCP Update Available" +- **Title**: "PowerPoint MCP Update Available" - **Message**: Shows current version, new version, and update command - **Duration**: 3 seconds (Windows standard) - **Type**: Info balloon (NotifyIcon.ShowBalloonTip) @@ -61,7 +58,7 @@ When the ExcelMCP Service starts, it automatically checks for updates in the bac - Thread-safe (invokes on UI thread if needed) - Integrates with existing tray icon -3. **`ExcelMcpService.cs`** - Service startup +3. **`PptMcpService.cs`** - Service startup - Triggers version check 5 seconds after startup - Runs in background Task.Run() to avoid blocking - Fails silently on any errors @@ -89,7 +86,7 @@ The current implementation uses classic balloon tips. For Windows 11, consider u Users can check for updates at any time using the `--version` flag: ```powershell -Sbroenne.ExcelMcp.McpServer.exe --version +PptMcp.McpServer.exe --version ``` This command: @@ -100,11 +97,11 @@ This command: **Example output when update is available:** ``` -Excel MCP Server v1.0.0 +PowerPoint MCP Server v1.0.0 Update available: 1.0.0 -> 1.1.0 -Run: dotnet tool update --global Sbroenne.ExcelMcp.McpServer -Release notes: https://github.com/sbroenne/mcp-server-excel/releases/latest +Run: dotnet tool update --global PptMcp.McpServer +Release notes: https://github.com/trsdn/mcp-server-ppt/releases/latest ``` ### Automatic Startup Logging @@ -118,8 +115,8 @@ When the MCP Server starts, it automatically checks for updates in the backgroun **Log Message:** ``` -info: Sbroenne.ExcelMcp.McpServer.Program[0] - MCP Server update available: 1.0.0 -> 1.1.0. Run: dotnet tool update --global Sbroenne.ExcelMcp.McpServer +info: PptMcp.McpServer.Program[0] + MCP Server update available: 1.0.0 -> 1.1.0. Run: dotnet tool update --global PptMcp.McpServer ``` **Why stderr?** The MCP protocol uses stdio for communication (stdin/stdout), so all logging goes to stderr to avoid interfering with the protocol. @@ -129,7 +126,7 @@ info: Sbroenne.ExcelMcp.McpServer.Program[0] **Components:** 1. **`NuGetVersionChecker.cs`** - NuGet API client - - Queries `https://api.nuget.org/v3-flatcontainer/sbroenne.excelmcp.mcpserver/index.json` + - Queries `https://api.nuget.org/v3-flatcontainer/PptMcp.mcpserver/index.json` - Returns latest non-prerelease version - 5-second timeout (inherited from HttpClient) @@ -177,7 +174,7 @@ The MCP Server includes application version in the `ServerInfo` response: ```json { - "name": "excel-mcp", + "name": "ppt-mcp", "version": "1.0.0" } ``` @@ -207,8 +204,8 @@ Adding a separate version check mechanism would: ### Unit Tests -**CLI Location**: `tests/ExcelMcp.CLI.Tests/Unit/ServiceVersionCheckerTests.cs` -**MCP Server Location**: `tests/ExcelMcp.McpServer.Tests/Unit/McpServerVersionCheckerTests.cs` +**CLI Location**: `tests/PptMcp.CLI.Tests/Unit/ServiceVersionCheckerTests.cs` +**MCP Server Location**: `tests/PptMcp.McpServer.Tests/Unit/McpServerVersionCheckerTests.cs` Tests verify: 1. Version comparison logic @@ -218,33 +215,33 @@ Tests verify: **Run CLI tests:** ```powershell -dotnet test tests/ExcelMcp.CLI.Tests/ExcelMcp.CLI.Tests.csproj --filter "Feature=VersionCheck" +dotnet test tests/PptMcp.CLI.Tests/PptMcp.CLI.Tests.csproj --filter "Feature=VersionCheck" ``` **Run MCP Server tests:** ```powershell -dotnet test tests/ExcelMcp.McpServer.Tests/ExcelMcp.McpServer.Tests.csproj --filter "Feature=VersionCheck" +dotnet test tests/PptMcp.McpServer.Tests/PptMcp.McpServer.Tests.csproj --filter "Feature=VersionCheck" ``` ### Manual Testing -**Test ExcelMCP Service notification:** -1. Start service via CLI: `excelcli session open ` (service starts automatically) +**Test PptMcp Service notification:** +1. Start service via CLI: `pptcli session open ` (service starts automatically) 2. Wait 5 seconds after startup 3. If update is available, Windows notification should appear in system tray **Test CLI version flag:** -1. Run: `excelcli --version` +1. Run: `pptcli --version` 2. Verify output shows current version and checks NuGet 3. If update available, message includes update command **Test MCP Server version flag:** -1. Run: `Sbroenne.ExcelMcp.McpServer.exe --version` +1. Run: `PptMcp.McpServer.exe --version` 2. Verify output shows current version and checks NuGet 3. If update available, message includes update command **Test MCP Server startup logging:** -1. Start MCP Server and redirect stderr: `Sbroenne.ExcelMcp.McpServer.exe 2> server.log` +1. Start MCP Server and redirect stderr: `PptMcp.McpServer.exe 2> server.log` 2. Wait 2 seconds after startup 3. Check `server.log` for update message (if update is available) @@ -262,12 +259,12 @@ These would require adding configuration to `ServiceVersionChecker` or service s ## Troubleshooting **CLI - No notification shown:** -- Check: Is an update actually available? Run `excelcli --version` to verify +- Check: Is an update actually available? Run `pptcli --version` to verify - Check: Network connectivity (version check requires internet to reach NuGet) - Check: Service logs for any errors during version check **MCP Server - No log message shown:** -- Check: Is an update actually available? Run `Sbroenne.ExcelMcp.McpServer.exe --version` to verify +- Check: Is an update actually available? Run `PptMcp.McpServer.exe --version` to verify - Check: Network connectivity (version check requires internet to reach NuGet) - Check: stderr output is not being suppressed (redirect stderr to see messages) - Note: Only logs if update is available - no message if up-to-date @@ -276,8 +273,8 @@ These would require adding configuration to `ServiceVersionChecker` or service s - Ensure you have internet connectivity - Verify NuGet package manager is working: `dotnet tool list --global` - Try updating manually: - - CLI: `dotnet tool update --global Sbroenne.ExcelMcp.CLI` - - MCP Server: `dotnet tool update --global Sbroenne.ExcelMcp.McpServer` + - CLI: `dotnet tool update --global PptMcp.CLI` + - MCP Server: `dotnet tool update --global PptMcp.McpServer` **Version check takes too long:** - Timeout is 5 seconds by default (from `NuGetVersionChecker`) diff --git a/docs/archive/AGENT-SKILLS-RESEARCH.md b/docs/archive/AGENT-SKILLS-RESEARCH.md deleted file mode 100644 index 74aa594c..00000000 --- a/docs/archive/AGENT-SKILLS-RESEARCH.md +++ /dev/null @@ -1,533 +0,0 @@ -# Agent Skills Research for AI Coding Assistants - -> **Comprehensive reference for implementing skills across VS Code/GitHub Copilot, Claude Code, and other AI coding assistants** - -## Table of Contents - -1. [Overview](#overview) -2. [VS Code / GitHub Copilot Skills](#vs-code--github-copilot-skills) -3. [Claude Code Skills](#claude-code-skills) -4. [Cross-Platform Skills (npx skills)](#cross-platform-skills-npx-skills) -5. [Other AI Assistants](#other-ai-assistants) -6. [Comparison Matrix](#comparison-matrix) -7. [Best Practices](#best-practices) - ---- - -## Overview - -Agent Skills are reusable instruction sets that extend AI coding assistants with domain-specific knowledge, workflows, and capabilities. They enable consistent, reliable behavior when working with specific tools, frameworks, or codebases. - -### Key Concepts - -| Term | Definition | -|------|------------| -| **Skill** | A self-contained instruction set with metadata (SKILL.md) | -| **Agent** | The AI coding assistant (Copilot, Claude Code, Cursor, etc.) | -| **Instructions** | Context-specific guidance files (.instructions.md) | -| **Prompts** | Reusable prompt templates (.prompt.md) | -| **Commands** | Slash commands for specific workflows | - ---- - -## VS Code / GitHub Copilot Skills - -### Directory Structure - -``` -~/.copilot/skills/ -├── {skill-name}/ -│ ├── SKILL.md # Main skill definition (required) -│ └── references/ # Optional supporting files -│ ├── api-reference.md -│ └── examples.md -``` - -### SKILL.md Format - -```yaml ---- -name: your-skill-name -description: Brief description of what this Skill does and when to use it -license: MIT -version: 1.0.0 -tags: - - keyword1 - - keyword2 -repository: https://github.com/owner/repo -documentation: https://docs.example.com ---- - -# Your Skill Name - -## Instructions -Provide clear, step-by-step guidance for the agent. - -## Examples -Show concrete examples of using this Skill. - -## Tool Map -List related tools and when to use them. - -## Reference Documentation -- references/api-reference.md -- references/examples.md -``` - -### Frontmatter Fields - -| Field | Required | Description | -|-------|----------|-------------| -| `name` | Yes | Unique identifier (kebab-case recommended) | -| `description` | Yes | Brief description shown to users | -| `license` | No | License identifier (e.g., MIT, Apache-2.0) | -| `version` | No | Semantic version (e.g., 1.0.0) | -| `tags` | No | Array of keywords for discovery | -| `repository` | No | Source code repository URL | -| `documentation` | No | Documentation website URL | - -### VS Code Settings - -```json -{ - "chat.useAgentSkills": true, - "github.copilot.chat.codeGeneration.useInstructionFiles": true -} -``` - -### Instructions Files (.instructions.md) - -Located in `.github/instructions/` or `.vscode/` directories: - -```markdown ---- -applyTo: "**/*.ts,**/*.tsx" ---- -# TypeScript Coding Standards - -## Guidelines -- Use TypeScript for all new code -- Prefer interfaces over type aliases -- Use strict null checks -``` - -#### applyTo Patterns - -| Pattern | Applies To | -|---------|------------| -| `"**"` | All files | -| `"**/*.ts"` | TypeScript files | -| `"src/**/*.py"` | Python files in src/ | -| `"docs/**/*.md"` | Markdown in docs/ | - -### Prompt Files (.prompt.md) - -Reusable prompts with metadata: - -```markdown ---- -agent: 'agent' -model: Claude Sonnet 4 -tools: ['githubRepo', 'search/codebase'] -description: 'Generate a new React form component' ---- -Your goal is to generate a new React form component... -``` - -### Custom Agents - -Located in `.github/agents/` or `.copilot/agents/`: - -```yaml ---- -name: Planner -displayName: Implementation Planner -description: Generate an implementation plan for features -tools: ['fetch', 'githubRepo', 'search', 'usages'] -model: Claude Sonnet 4 -handoffs: - - label: Implement Plan - agent: agent - prompt: Implement the plan outlined above. - send: false ---- - -# Planning Instructions -You are in planning mode. Generate an implementation plan... -``` - ---- - -## Claude Code Skills - -### Directory Structure - -``` -.claude/skills/ -├── {skill-name}/ -│ ├── SKILL.md # Main skill definition (required) -│ ├── REFERENCE.md # Optional reference docs -│ └── scripts/ # Optional utility scripts -│ └── validate.py - -# Or global skills: -~/.claude/skills/ -└── {skill-name}/ - └── SKILL.md -``` - -### SKILL.md Format - -```yaml ---- -name: your-skill-name -description: Brief description with trigger terms. Use when working with X or Y. -allowed-tools: Read, Grep, Glob, Bash(python:*) -user-invocable: true -context: fork -hooks: - PreToolUse: - - matcher: "Bash" - hooks: - - type: command - command: "./scripts/security-check.sh" ---- - -# Your Skill Name - -## Instructions -1. First step -2. Second step - -## Examples -Show concrete usage examples. -``` - -### Frontmatter Fields - -| Field | Required | Description | -|-------|----------|-------------| -| `name` | Yes | Unique skill identifier | -| `description` | Yes | Description including trigger terms | -| `allowed-tools` | No | Restrict available tools (comma-separated or array) | -| `user-invocable` | No | `true` (default) = appears in slash menu, `false` = model-only | -| `context` | No | `fork` = isolated sub-agent context | -| `hooks` | No | Hook configurations for the skill lifecycle | - -### Tool Restrictions - -```yaml -# Comma-separated -allowed-tools: Read, Grep, Glob - -# Array format -allowed-tools: - - Read - - Grep - - Glob - - Bash(python:*) # Only allow python commands -``` - -### File Imports - -Reference other files within SKILL.md: - -```markdown -See @README for project overview. -For details, see @docs/api-reference.md. - -# Import from home directory -- @~/.claude/my-project-instructions.md -``` - -### Slash Commands - -Located in `.claude/commands/`: - -```markdown ---- -description: Review code for quality issues -allowed-tools: Bash(git add:*), Bash(git status:*) -hooks: - PreToolUse: - - matcher: "Bash" - hooks: - - type: command - command: "./scripts/pre-review.sh" ---- - -## Context -- Current git status: !`git status` -- Current branch: !`git branch --show-current` - -## Your task -Review the staged changes for code quality issues. -``` - -### CLAUDE.md (Project Instructions) - -Root-level project configuration: - -```markdown -# Project Instructions - -## Overview -Brief project description. - -## Development Setup -- Required tools and versions -- Environment setup - -## Coding Standards -- Style guidelines -- Naming conventions - -## Testing -- How to run tests -- Coverage requirements -``` - -### Sub-Agents - -Located in `.claude/agents/`: - -```yaml ---- -name: code-reviewer -description: Review code for quality and best practices -tools: Read, Grep, Glob -model: sonnet -permissionMode: default -skills: pr-review, security-check ---- - -You are a code reviewer. Analyze code for: -1. Code organization -2. Error handling -3. Security concerns -4. Test coverage -``` - ---- - -## Cross-Platform Skills (npx skills) - -### Installation - -```powershell -# From GitHub shorthand -npx skills add vercel-labs/agent-skills - -# From specific skill -npx skills add vercel-labs/agent-skills --skill frontend-design - -# Install globally -npx skills add vercel-labs/agent-skills --global - -# Install for specific agents -npx skills add vercel-labs/agent-skills -a claude-code -a cursor -``` - -### Supported Agents - -| Agent | Project Directory | Global Directory | -|-------|-------------------|------------------| -| `claude-code` | `.claude/skills/` | `~/.claude/skills/` | -| `cursor` | `.cursor/skills/` | `~/.cursor/skills/` | -| `github-copilot` | `.copilot/skills/` | `~/.copilot/skills/` | -| `codex` | `.codex/skills/` | `~/.codex/skills/` | -| `gemini-cli` | `.gemini/skills/` | `~/.gemini/skills/` | -| `opencode` | `.opencode/skills/` | `~/.opencode/skills/` | -| `windsurf` | `.windsurf/skills/` | `~/.windsurf/skills/` | -| `kilo` | `.kilo/skills/` | `~/.kilo/skills/` | -| `goose` | `.goose/skills/` | `~/.goose/skills/` | -| **And 34+ more** | See `npx skills add --help` | See `npx skills add --help` | - -### Skill Discovery Locations - -``` -# Search priority (in order): -1. Root directory (if contains SKILL.md) -2. skills/, skills/.curated/, skills/.experimental/ -3. .claude/skills/, .cursor/skills/, .opencode/skills/ -4. Recursive search (fallback) -``` - ---- - -## Other AI Assistants - -### Cursor - -**Configuration file:** `.cursorrules` (root of project) - -```markdown -# Project Rules -- Prefer using yarn -- Generated commit messages should be in English -- Use TypeScript strict mode -``` - -**Additional locations:** -- `.cursor/skills/` - Skills directory -- `.cursor/instructions/` - Instructions files - -### Windsurf/Codeium - -**Configuration:** `.windsurf/skills/` directory - -Uses similar SKILL.md format to other agents. - -### Gemini CLI - -**Configuration:** `.gemini/skills/` directory - -Follows the npx skills specification format. - ---- - -## Comparison Matrix - -| Feature | GitHub Copilot | Claude Code | Cursor | Windsurf | -|---------|---------------|-------------|--------|----------| -| **Skills Directory** | `~/.copilot/skills/` | `.claude/skills/` | `.cursor/skills/` | `.windsurf/skills/` | -| **Main File** | `SKILL.md` | `SKILL.md` | `SKILL.md` | `SKILL.md` | -| **Instructions** | `.instructions.md` | CLAUDE.md + imports | `.cursorrules` | `.instructions.md` | -| **Commands** | `.prompt.md` | `.claude/commands/` | N/A | N/A | -| **Custom Agents** | `.agent.yaml` | `.claude/agents/` | N/A | N/A | -| **Tool Restrictions** | Via settings | `allowed-tools` | N/A | N/A | -| **Hooks** | N/A | Full lifecycle | N/A | N/A | -| **MCP Support** | Yes | Yes | Limited | Limited | -| **applyTo Patterns** | Yes | Via description | N/A | N/A | - ---- - -## Best Practices - -> **Source:** Official documentation from [agentskills.io](https://agentskills.io/specification), [Claude Code](https://code.claude.com/docs/en/skills), [VS Code Copilot](https://code.visualstudio.com/docs/copilot/copilot-customization), and [vercel-labs/skills](https://github.com/vercel-labs/skills) - researched 2026-02-02. - -### Official Specification Requirements - -From **agentskills.io/specification**: - -| Field | Required | Constraints | -|-------|----------|-------------| -| `name` | Yes | 1-64 chars, lowercase alphanumeric + hyphens, no leading/trailing hyphens, no `--` | -| `description` | Yes | 1-1024 chars, describes WHAT + WHEN to use | -| `license` | No | License name or reference to bundled file | -| `compatibility` | No | 1-500 chars, environment requirements (OS, packages, network) | -| `metadata` | No | Key-value map for custom properties | -| `allowed-tools` | No | Space-delimited pre-approved tools (experimental) | - -### SKILL.md Size Guidelines (OFFICIAL) - -From **agentskills.io** and **Claude Code docs**: - -| Layer | Token Budget | Content | -|-------|-------------|---------| -| Metadata | ~100 tokens | `name` + `description` (loaded at startup for ALL skills) | -| Instructions | **< 5000 tokens** | Full SKILL.md body (loaded when skill activated) | -| Resources | As needed | Files in `scripts/`, `references/`, `assets/` | - -**Official recommendation:** Keep SKILL.md **under 500 lines**. Move detailed reference material to separate files. - -### Progressive Disclosure Pattern - -From **agentskills.io**: - -1. **Discovery:** Agents load only `name` + `description` at startup -2. **Activation:** When task matches description, agent reads full SKILL.md -3. **Execution:** Agent follows instructions, loading referenced files as needed - -**Why this matters:** Keep main skill lean. Don't front-load everything. - -### Effective Descriptions (Official Examples) - -From **agentskills.io/specification**: - -```yaml -# Good - describes WHAT + WHEN with keywords -description: Extracts text and tables from PDF files, fills PDF forms, and merges multiple PDFs. Use when working with PDF documents or when the user mentions PDFs, forms, or document extraction. - -# Bad - vague -description: Helps with PDFs. -``` - -### Using the `compatibility` Field - -From **agentskills.io** - use this for platform/environment requirements: - -```yaml ---- -name: excel-automation -description: Automate Excel workbooks with Power Query, VBA, and data operations. -compatibility: Requires Windows + Microsoft Excel 2016+ (COM interop). Does NOT work on macOS/Linux. ---- -``` - -### Directory Structure (Official) - -From **agentskills.io**: - -``` -skill-name/ -├── SKILL.md # Required - instructions + metadata -├── scripts/ # Optional - executable code -├── references/ # Optional - detailed documentation -└── assets/ # Optional - templates, resources -``` - -### Skill Content Types (Claude Code) - -From **code.claude.com/docs/en/skills**: - -| Type | Purpose | Invocation | -|------|---------|------------| -| **Reference** | Knowledge Claude applies to current work (conventions, patterns) | Auto-loaded when relevant | -| **Task** | Step-by-step instructions for specific actions (deploy, commit) | Manual via `/skill-name` | - -Use `disable-model-invocation: true` for tasks you want manual control over. - -### Cross-Platform Compatibility - -Skills work across 38+ agents including: -- GitHub Copilot: `.github/skills/` or `~/.copilot/skills/` -- Claude Code: `.claude/skills/` or `~/.claude/skills/` -- Cursor: `.cursor/skills/` -- OpenCode, Codex, Windsurf, Roo Code, etc. - -**To maximize compatibility:** -1. Use only standard frontmatter fields (`name`, `description`, `compatibility`) -2. Keep instructions in standard Markdown -3. Avoid agent-specific features in shared content -4. Test with `npx skills add --list` before publishing - -### CLI Skills: Emphasize Discovery - -For CLI-based skills, don't document every parameter. Emphasize `--help`: - -```markdown -## Core Principle -Use `--help` for parameters. `excelcli --help` is the authoritative source. -``` - -**Why:** CLI help is always current; documentation gets stale. - ---- - -## References - -- [VS Code Copilot Customization](https://code.visualstudio.com/docs/copilot/customization) -- [Claude Code Skills Documentation](https://code.claude.com/docs/en/skills) -- [skills CLI](https://github.com/vercel-labs/skills) (formerly add-skill) -- [MCP Server Protocol](https://modelcontextprotocol.io/) - ---- - -## Changelog - -| Date | Change | -|------|--------| -| 2026-02 | Updated `add-skill` references to `skills` (command renamed), expanded agent support list | -| 2026-02 | Added SKILL.md size guidelines, requirements visibility, CLI discovery patterns | -| 2025-01 | Initial research document | diff --git a/docs/archive/API-COMPARISON-REPORT.md b/docs/archive/API-COMPARISON-REPORT.md deleted file mode 100644 index ce8adb1e..00000000 --- a/docs/archive/API-COMPARISON-REPORT.md +++ /dev/null @@ -1,421 +0,0 @@ -# API Surface Comparison Report - -> **Generated:** February 6, 2026 (updated after `path` removal cleanup from non-file MCP tools) -> **Branch:** `feature/mcp-daemon-unification` vs `main` -> **PR:** [#433](https://github.com/sbroenne/mcp-server-excel/pull/433) -> **Method:** -> - CLI: Both branches freshly built (`dotnet build src/ExcelMcp.CLI -c Release`), `--help` parsed -> - MCP: Both branches built, MCP Server queried via **live MCP protocol** (`tools/list`) using `@modelcontextprotocol/sdk` Node.js client - ---- - -## Executive Summary - -### CLI (Command-Line Interface) - -| Area | Main | Branch | Status | -|------|------|--------|--------| -| **Commands** | 15 | 15 | ✅ Same command names | -| **Total Actions** | 174 | 197 | ⚠️ +23 (pivottable merge), 1 rename | -| **Total Parameters** | 251 | 256 | ⚠️ Renames in 9 of 15 commands | -| **Identical Commands** | — | — | 6 of 15 (chart, chartconfig, datamodelrel, range, sheet, slicer) | - -### MCP Server (Model Context Protocol) - -| Area | Main | Branch | Status | -|------|------|--------|--------| -| **MCP Tools** | 23 | 23 | ✅ Same tool count | -| **Total Actions** | 215 | 215 | ✅ Same action count | -| **Total Parameters** | 297 | 287 | ⚠️ **-10 params** | -| **`excelPath` Removed** | — | — | ⚠️ 11 session-based tools (no longer need file path with daemon) | -| **Parameter Renames** | — | — | ⚠️ `file` + `datamodel` + `datamodel_relationship` | -| **Changed Tools** | — | — | ⚠️ 13 of 23 | -| **Identical Tools** | — | — | ✅ 10 of 23 | - -### Key Findings - -1. **MCP Server: `file` parameters renamed** — `excelPath` → `path` and `showExcel` → `show`. All other MCP tools are identical by schema. -2. **CLI has breaking changes** — 1 action rename (`add-to-datamodel` → `add-to-data-model`), parameter renames in 9/15 commands, and pivottable merges +23 actions. -3. **Actions are identical across both layers** — All 215 MCP actions and all action enum values are preserved 1:1. -4. **CLI pivottable absorbs +23 actions** from pivottablefield/pivottablecalc into one CLI command. -5. **No tools were added or removed** — All 23 MCP tools exist on both branches. -6. **Total params unchanged** — 287 → 287 across both branches. - ---- - -# Part 1: CLI Comparison - -> **Method:** `dotnet build src/ExcelMcp.CLI -c Release` on each branch, then `excelcli.exe --help` parsed for all 15 commands. - -## Identical Commands (6/15) - -These commands have **zero differences** in actions or parameters between main and branch: - -| Command | Actions | Params | -|---------|---------|--------| -| chart | 8 | 14 | -| chartconfig | 21 | 36 | -| datamodelrel | 5 | 6 | -| range | 42 | 56 | -| sheet | 16 | 14 | -| slicer | 8 | 14 | - -## Action Changes (2/15) - -### `pivottable`: 7 → 30 actions (+23 new) - -Main exposes only 7 lifecycle actions via CLI. Branch merges field and calc actions into the same `pivottable` CLI command: - -| Source Category | Actions Merged In | -|----------------|-------------------| -| pivottablefield (13) | `list-fields`, `add-row-field`, `add-column-field`, `add-value-field`, `add-filter-field`, `remove-field`, `set-field-function`, `set-field-name`, `set-field-format`, `set-field-filter`, `sort-field`, `group-by-date`, `group-by-numeric` | -| pivottablecalc (10) | `get-data`, `create-calculated-field`, `list-calculated-fields`, `delete-calculated-field`, `list-calculated-members`, `create-calculated-member`, `delete-calculated-member`, `set-layout`, `set-subtotals`, `set-grand-totals` | - -> **Impact:** Additive only — all 7 original actions preserved. In main, these 23 actions exist in Core but the CLI routing was fragmented. Branch unifies them under one command. - -### `table`: 1 action renamed - -| Main | Branch | Impact | -|------|--------|--------| -| `add-to-datamodel` | `add-to-data-model` | ⚠️ **Breaking** — hyphenation changed | - -## Parameter Changes (7/15) - -These commands have identical action lists but **renamed or restructured parameters**: - -### `calculationmode` (3 actions, params 5→5) - -| Main | Branch | Semantic Change | -|------|--------|----------------| -| `--sheet` | `--sheet-name` | None | -| `--range` | `--range-address` | None | - -### `conditionalformat` (2 actions, params 7→14) - -Complete restructure — main used generic params, branch exposes all formatting properties individually: - -**Removed:** -| Main Param | Notes | -|-----------|-------| -| `--formula` | Replaced by `--formula1` | -| `--formula-file` | Dropped | -| `--format-style` | Replaced by individual properties | -| `--sheet` | Renamed to `--sheet-name` | -| `--range` | Renamed to `--range-address` | - -**Added:** -| Branch Param | Notes | -|-------------|-------| -| `--sheet-name` | Renamed from `--sheet` | -| `--range-address` | Renamed from `--range` | -| `--operator-type` | New — explicit operator specification | -| `--formula1` | Replaces `--formula` | -| `--formula2` | New — for between/not-between | -| `--interior-color` | New — individual formatting | -| `--interior-pattern` | New | -| `--font-color` | New | -| `--font-bold` | New | -| `--font-italic` | New | -| `--border-style` | New | -| `--border-color` | New | - -### `connection` (9 actions, params 16→11) - -| Main Param | Branch Param | Notes | -|-----------|-------------|-------| -| `--connection` | `--connection-name` | Renamed | -| `--sheet` | `--sheet-name` | Renamed | -| `--connection-type` | — | Removed | -| `--connection-string-file` | — | Removed | -| `--command-text-file` | — | Removed | -| `--load-destination` | — | Removed | -| `--target-cell` | — | Removed | -| `--refresh-on-open` | `--refresh-on-file-open` | Renamed | -| `--enable-refresh` | — | Removed | -| — | `--timeout` | New | - -### `datamodel` (14 actions, params 12→14) - -| Main Param | Branch Param | Notes | -|-----------|-------------|-------| -| `--table` | `--table-name` | Renamed | -| `--measure` | `--measure-name` | Renamed | -| `--expression` | `--dax-formula` | Renamed (more specific) | -| `--expression-file` | `--dax-formula-file` | Renamed | -| `--format-string` | `--format-type` | Renamed | -| `--max-rows` | — | Removed | -| — | `--old-name` | New | -| — | `--timeout` | New | -| — | `--description` | New | - -### `namedrange` (6 actions, params 5→4) - -| Main Param | Branch Param | Notes | -|-----------|-------------|-------| -| `--name` | `--param-name` | Renamed | -| `--refers-to` | `--reference` | Renamed | -| `--sheet-scope` | — | Removed | - -### `powerquery` (12 actions, params 8→11) - -| Main Param | Branch Param | Notes | -|-----------|-------------|-------| -| `--query` | `--query-name` | Renamed | -| `--mcode` | `--m-code` | Renamed (hyphenated) | -| `--mcode-file` | `--m-code-file` | Renamed | -| `--target-cell` | `--target-cell-address` | Renamed | -| — | `--timeout` | New | -| — | `--refresh` | New | -| — | `--old-name` | New | - -### `vba` (6 actions, params 8→7) - -| Main Param | Branch Param | Notes | -|-----------|-------------|-------| -| `--module` | `--module-name` | Renamed | -| `--macro` | — | Removed (replaced by `--procedure-name`) | -| `--code` | `--vba-code` | Renamed | -| `--code-file` | `--vba-code-file` | Renamed | -| `--module-type` | — | Removed | -| `--arguments` | `--parameters` | Renamed | -| — | `--procedure-name` | New (replaces `--macro`) | - -## CLI Parameter Rename Patterns - -| Pattern | Examples | Count | -|---------|----------|-------| -| Short → descriptive | `--sheet` → `--sheet-name`, `--table` → `--table-name` | ~8 | -| Abbreviated → full | `--mcode` → `--m-code`, `--macro` → `--procedure-name` | ~5 | -| Generic → specific | `--expression` → `--dax-formula`, `--formula` → `--formula1` | ~3 | -| New params added | `--timeout`, `--description`, `--old-name`, `--refresh` | ~6 | -| Params removed | `--max-rows`, `--module-type`, `--sheet-scope`, `--load-destination` | ~5 | - -**Root cause:** Branch generates CLI params directly from Core interface method parameter names (PascalCase → kebab-case). Main had hand-written CLI commands with different naming conventions. - ---- - -# Part 2: MCP Server Comparison - -> **Method:** Both branches built (`dotnet build src/ExcelMcp.McpServer -c Release`), then queried via **live MCP protocol** using `@modelcontextprotocol/sdk` Node.js client. The client connects to the server via stdio transport and calls `tools/list` to get the actual JSON schemas exposed to LLM clients. -> -> **Scripts:** `scripts/mcp-tools-capture.mjs` (capture), `scripts/compare-mcp-tools.mjs` (comparison) -> **Data files:** `main-mcp-tools.json`, `branch-mcp-tools.json`, `mcp-comparison-result.json` - -## Result: 13 Tools Changed, 10 Identical - -### Summary - -| Metric | Main | Branch | Status | -|--------|------|--------|--------| -| Tool count | 23 | 23 | ✅ Same | -| Total actions | 215 | 215 | ✅ Same | -| Total parameters | 297 | 287 | ⚠️ **-10 params** (net) | -| `excelPath` removals | — | 11 tools | ⚠️ All session-based tools (daemon architecture change) | -| Parameter renames | — | 8 params | ⚠️ `file` (2), `datamodel` (2), `datamodel_relationship` (5) + 1 removal | -| New params added | — | 6 params | ✅ `datamodel` file-based inputs + timeout | -| Params removed | — | 4 params | ⚠️ `connection` set-properties cleanup | - -### Identical Tools (10/23) - -These tools have **zero differences** in actions, parameters, or descriptions between main and branch: - -| MCP Tool | Actions | Params | Notes | -|----------|---------|--------|-------| -| `chart` | 8 | 14 | All chart lifecycle | -| `chart_config` | 21 | 44 | Chart configuration | -| `pivottable` | 7 | 10 | PivotTable lifecycle | -| `pivottable_calc` | 10 | 11 | Calculated fields/members | -| `pivottable_field` | 13 | 14 | Field configuration | -| `powerquery` | 12 | 10 | Power Query operations | -| `slicer` | 8 | 11 | Slicer operations | -| `worksheet` | 8 | 8 | Sheet lifecycle | -| `worksheet_style` | 8 | 7 | Sheet styling | -| `file` | 6 | 6 | ⚠️ File management (params ARE renamed — see below) | - -> **Note:** `file` is listed here for action/param count only. It has 2 parameter renames (see Category 2). - -### Category 1: `excelPath` Removal from Session-Based Tools (11 tools, -11 params) - -These tools are **session-based** (operate within an existing Excel file context managed by the daemon). The `excelPath` parameter was a legacy artifact from pre-daemon architecture where tools needed the file path on every call. With the daemon, the session already knows the file context. - -**Architecture Rationale:** MCP daemon architecture centralizes session management. The client opens a file once (`file` with `action=open`), receives a `sessionId`, then all subsequent operations use only the `sessionId`. The daemon tracks which file each session points to, eliminating the need for `excelPath` on every tool call. - -| MCP Tool | Param Removed | Notes | -|----------|--------------|-------| -| `calculation_mode` | `excelPath` | -1 param (7 → 6) | -| `conditionalformat` | `excelPath` | -1 param (16 → 15) | -| `connection` | `excelPath` | -1 param (15 → 11, but also -3 from Category 3) | -| `namedrange` | `excelPath` | -1 param (5 → 4) | -| `range` | `excelPath` | -1 param (14 → 13) | -| `range_edit` | `excelPath` | -1 param (15 → 14) | -| `range_format` | `excelPath` | -1 param (33 → 32) | -| `range_link` | `excelPath` | -1 param (10 → 9) | -| `table` | `excelPath` | -1 param (12 → 11) | -| `table_column` | `excelPath` | -1 param (11 → 10) | -| `vba` | `excelPath` | -1 param (7 → 6) | - -**Impact:** This is a **breaking change** for direct MCP clients that were passing `excelPath` to these tools. However, it's an **architectural improvement** — tools now correctly reflect that they operate within a session context, not independently on arbitrary files. - -### Category 2: `file` Parameter Renames (1 tool, 2 params renamed) - -#### `file` (6 actions, 6 params → 6 params) - -| Main Param | Branch Param | Notes | -|-----------|-------------|-------| -| `excelPath` | `path` | Renamed (shorter, more generic) | -| `showExcel` | `show` | Renamed (shorter, boolean clarity) | - -**Rationale:** `file` is the **only** MCP tool that retains a file path parameter because it's the file management/session creation tool. The rename to `path` and `show` aligns with modern MCP conventions (shorter param names, boolean clarity). - -**Impact:** ⚠️ **Breaking change** — MCP clients calling `file` must update param names. - -### Category 3: Additional Parameter Changes (3 tools) - -#### `connection` (9 actions, 15 params → 11 params, -4 params) - -| Main Param | Branch Param | Notes | -|-----------|-------------|-------| -| `excelPath` | — | **Removed** (Category 1) | -| `newCommandText` | — | **Removed** | -| `newConnectionString` | — | **Removed** | -| `newDescription` | — | **Removed** | - -**Rationale:** The `SetProperties` action now reuses existing params (`commandText`, `connectionString`, `description`) instead of requiring separate `new*` params. This simplifies the API — the same params are used for both create and update scenarios. - -**Impact:** ⚠️ **Breaking change** — `SetProperties` calls must use standard param names, not `new*` prefixed versions. - -#### `datamodel` (14 actions, 10 params → 14 params, +4 params) - -**Renames:** -| Main Param | Branch Param | Notes | -|-----------|-------------|-------| -| `formatString` | `formatType` | Semantic clarification (type vs string) | -| `newTableName` | `newName` | Shorter, more generic | - -**Additions:** -| Branch Param | Notes | -|-------------|-------| -| `daxFormulaFile` | File-based DAX formula input (alternative to inline `daxFormula`) | -| `daxQueryFile` | File-based DAX query input | -| `dmvQueryFile` | File-based DMV query input | -| `timeout` | Timeout for long-running operations | - -**Rationale:** File-based inputs allow large DAX formulas/queries to be passed via file paths instead of inline strings. `timeout` supports long-running data model operations. `formatString` → `formatType` clarifies that this param expects a format type enum/string, not a custom format string. - -**Impact:** ✅ **Additive** — new params are optional. ⚠️ **Breaking** — `formatString` rename requires update. - -#### `datamodel_relationship` (5 actions, 7 params → 7 params, 5 renames) - -**All Parameters Renamed (Shorter):** -| Main Param | Branch Param | Notes | -|-----------|-------------|-------| -| `fromTableName` | `fromTable` | Shorter | -| `toTableName` | `toTable` | Shorter | -| `fromColumnName` | `fromColumn` | Shorter | -| `toColumnName` | `toColumn` | Shorter | -| `isActive` | `active` | Shorter, boolean clarity | - -**Rationale:** Shorter param names improve readability and align with modern MCP conventions. The `is` prefix on booleans is redundant in a typed schema where the type is already declared as boolean. - -**Impact:** ⚠️ **Breaking change** — All relationship-related MCP calls must update ALL 5 param names. - -### MCP Parameter Change Patterns - -| Pattern | Examples | Count | -|---------|----------|-------| -| `excelPath` removed (session-based) | 11 tools (all session-based operations) | 11 | -| File param rename: `excelPath` → `path` | `file` only | 1 | -| Boolean clarity: `showExcel` → `show` | `file` | 1 | -| Verbose → shorter | `fromTableName` → `fromTable`, `isActive` → `active` | 5 | -| Semantic rename | `formatString` → `formatType`, `newTableName` → `newName` | 2 | -| New file-based inputs | `daxFormulaFile`, `daxQueryFile`, `dmvQueryFile` | 3 | -| New timeout param | `datamodel` | 1 | -| Removed `new*` params | `newCommandText`, `newConnectionString`, `newDescription` | 3 | - -**Root cause:** Branch generates MCP tool parameters from Core interface method signatures. Main had hand-written MCP tool methods with: -- Redundant `excelPath` parameters on session-based tools (never functionally needed — session already knows the file) -- Verbose parameter names from early design iterations -- Separate `new*` params instead of reusing existing params for update operations - ---- - -# Part 3: Items Requiring Decision - -### 1. `add-to-datamodel` vs `add-to-data-model` (CLI Breaking Change) - -The CLI shows `add-to-data-model` (hyphenated) on branch vs `add-to-datamodel` on main. - -**Note:** The MCP Server shows the same action enum value (`AddToDataModel`) on both branches — the wire format is determined by the enum, not the hyphenation. This is **CLI-only**. - -**User Decision:** **B** — keep `add-to-data-model` (accept breaking change) - -### 2. CLI Parameter Renames (Breaking for CLI scripts only) - -CLI scripts using `--sheet`, `--query`, `--mcode`, `--table`, `--module`, etc. will break. - -**Options:** -- A) Add aliases in code generator for backward-compatible param names -- B) Accept as clean break (document in migration guide) - -### 3. MCP `excelPath` Removed from Session-Based Tools (Intentional Cleanup) - -**11 of 23 MCP tools** had `excelPath` removed entirely (not renamed). Only `file` retains a file path parameter (`path`, renamed from `excelPath`). Additionally, `file` renames `showExcel` to `show`. - -**Decision: RESOLVED** — `excelPath` was a legacy artifact in session-based tools. These tools use `sessionId` and never needed a file path. The parameter was only used for error messages and telemetry, with 4 tools even having `_ = path;` discard statements. Removal is an API improvement, not a breaking change in the traditional sense — the parameter was always redundant. - -### 4. MCP `connection` — 3 Params Removed - -`newCommandText`, `newConnectionString`, `newDescription` removed from `connection`. The `SetProperties` action on branch reuses existing params instead. - -**Impact:** MCP clients using `SetProperties` with `new*` params will break. Lower impact since `SetProperties` is rarely used directly. - -### 5. MCP `datamodel` — Parameter Restructure - -- `formatString` → `formatType` (rename) -- `newTableName` → `newName` (rename) -- 4 new params: `daxFormulaFile`, `daxQueryFile`, `dmvQueryFile`, `timeout` - -**Impact:** Clients using `formatString` or `newTableName` will break. New params are additive. - -### 6. MCP `datamodel_relationship` — All 5 Params Renamed - -All non-action params renamed to shorter forms (`fromTableName` → `fromTable`, etc.). Any client code for relationship management will break. - ---- - -# Appendix: Methodology - -## Data Sources - -| File | Contents | Branch | -|------|----------|--------| -| `main-cli-full.json` | CLI `--help` parsed output | main (built 2026-02-06) | -| `branch-cli-full.json` | CLI `--help` parsed output | branch (built 2026-02-06) | -| `main-mcp-tools.json` | MCP `tools/list` response (297 params) | main (live protocol capture, `net10.0` binary) | -| `branch-mcp-tools-v2.json` | MCP `tools/list` response (287 params) | branch (post-cleanup, `net10.0-windows` binary) | -| `branch-mcp-tools.json` | MCP `tools/list` response (298 params, pre-cleanup) | branch (before `path` removal) | -| `mcp-comparison-result.json` | Structured comparison of MCP schemas | both | -| `main-actions-source.json` | `ActionExtensions.cs` action strings (22 categories) | main | -| `branch-actions-source.json` | `[ServiceAction]` from `I*Commands.cs` (20 categories) | branch | -| `cli-comparison-result.txt` | Full CLI diff output | both | -| `action-comparison.txt` | Source-level action diff | both | - -## Process - -### CLI Comparison -1. `git stash` → `git checkout main` → `dotnet build src/ExcelMcp.CLI -c Release` → capture `--help` from `net10.0-windows/excelcli.exe` -2. `git checkout feature/mcp-daemon-unification` → `git stash pop` → `dotnet build src/ExcelMcp.CLI -c Release` → capture `--help` -3. Compare actions and parameters per CLI command - -### MCP Server Comparison (Live Protocol) -1. `dotnet build src/ExcelMcp.McpServer -c Release` on branch → captures from `net10.0-windows/` → `branch-mcp-tools.json` -2. `git stash` → `git checkout main` → `dotnet clean` + `dotnet build src/ExcelMcp.McpServer -c Release` → captures from `net10.0/` → `main-mcp-tools.json` -3. `git checkout feature/mcp-daemon-unification` → `git stash pop` -4. Both captures used `scripts/mcp-tools-capture.mjs` — a Node.js MCP client using `@modelcontextprotocol/sdk` that: - - Connects to the MCP Server via stdio transport (`--transport stdio`) - - Sends `initialize` and `tools/list` JSON-RPC requests - - Captures full tool schemas: names, descriptions, action enums, parameter names/types/enums/required -5. `scripts/compare-mcp-tools.mjs` — performs deep comparison of all 23 tools across both capture files - -### Key Insight: Different TFMs -Main builds to `net10.0` while branch builds to `net10.0-windows`. Both produce binaries at different paths under `bin/Release/`. A clean build (`dotnet clean` + `dotnet build`) is required when switching branches to avoid loading stale DLLs from the cache. diff --git a/docs/archive/IMPLEMENTATION_STATUS.md b/docs/archive/IMPLEMENTATION_STATUS.md deleted file mode 100644 index 2958717c..00000000 --- a/docs/archive/IMPLEMENTATION_STATUS.md +++ /dev/null @@ -1,145 +0,0 @@ -# Excel MCP Improvements - Test-Driven Development Setup - -## Completed Work - -### 1. **Formula Validation Result Types** ✅ - -Added comprehensive result type classes to `ResultTypes.cs`: - -- **`RangeFormulaValidationResult`** - Main validation result with error/warning lists -- **`FormulaValidationError`** - Cell-level error with suggestions (missing namespace, syntax errors, invalid references) -- **`FormulaValidationWarning`** - Warnings for technical issues (circular references, deprecated functions) -- **`RangeCellError`** - CellErrors collection in formula results for error code mapping - -### 2. **IRangeCommands Interface Update** ✅ - -Added new service action to `IRangeCommands`: - -- **`ValidateFormulas()`** - Validates formula syntax without applying, detects: - - Undefined functions (detects missing XA2. namespace) - - Syntax errors (unclosed parentheses) - - Invalid sheet references - - Empty formulas (skipped validation) - - Provides actionable suggestions for fixes - -### 3. **ValidateFormulas Implementation** ✅ - -Created `RangeCommands.FormulaValidation.cs` with: - -- Single formula validation logic with regex-based detection -- Common Excel add-in function namespace checking (GETVM3, GETAKS, GETDISK, etc.) -- Cell address generation (row/col → A1, B5 format) -- Error categorization (syntax-error, undefined-function, invalid-reference) -- Integration with batch API - -### 4. **Comprehensive Test Suite** ✅ - -#### **Test File 1: `RangeCommandsTests.FormulaValidation.cs`** - -- 8 new test methods for formula validation -- Tests for undefined functions with XA2 namespace detection -- Syntax error detection (unclosed parentheses) -- Invalid reference detection -- Error code mapping (Excel error codes → human-readable messages) -- Cell error collections - -#### **Test File 2: `RangeCommandsTests.SmartDetection.cs`** - -- 7 new test methods for smart formula detection in set-values -- Tests for auto-detecting `=` prefix in set-values -- Mixed formula/value array handling -- Literal `=` text escaping with single quote prefix -- Multiple formula applications -- Batch formula operations - -#### **Test File 3: `FileCommandsTests.IrmDetection.cs`** - -- 5 new test methods for IRM protection detection -- Normal file detection (no IRM) -- IRM-protected file detection -- File validation info structure -- RVTools export pattern testing - -## Test Coverage Summary - -| Feature | Test Count | Key Tests | -| ------------------ | ---------- | --------------------------------------------------------------- | -| Formula Validation | 8 | Valid formulas, undefined functions, syntax errors, cell errors | -| Smart Detection | 7 | Auto-detect formulas, mixed arrays, escape handling | -| IRM Detection | 5 | Normal files, IRM files, validation info | -| **Total** | **20** | - | - -## Implementation Status - -| Improvement | Status | Details | -| ----------------------------- | ----------- | --------------------------------------------------- | -| #1: Formula Syntax Validation | ✅ Ready | Interface + implementation + tests | -| #2: Smart Formula Detection | 🔄 Designed | Tests ready, implementation pending | -| #4: Error Code Mapping | ✅ Partial | Result types + tests ready | -| #5: IRM Detection | ✅ Existing | FileValidationInfo.IsIrmProtected already supported | - -## Build Status - -- ✅ **ExcelMcp.Core** - Builds successfully (0 errors, 0 warnings) -- ✅ **ExcelMcp.Core.Tests** - Builds successfully (0 errors, 0 warnings) -- ✅ **All test files compile** - 20 new test methods ready -- ✅ **Code formatting** - All IDE0055 issues resolved -- ✅ **Locale issues** - All CA1305 warnings fixed with CultureInfo.InvariantCulture - -## Test Execution (Ready for Integration) - -Tests are ready to run with: - -```bash -# Run the formula validation tests -dotnet test --filter "Feature=Range&Feature=FormulaValidation" - -# Run the smart detection tests -dotnet test --filter "Feature=Range&Feature=SmartDetection" - -# Run the IRM detection tests -dotnet test --filter "Feature=Files&Feature=IrmDetection" - -# Run all validation/improvement tests -dotnet test --filter "Feature=Range|Feature=Files" -``` - -## Next Steps - -1. **Implement `SetValues` Auto-Detection** (#2) - - Detect `=` prefix in `SetValues` parameter - - Automatically call `SetFormulas` if detected - - Return mode info (formula_detected) - -2. **Enhance `GetFormulas` Error Mapping** (#4) - - Detect Excel error codes from formula evaluation - - Map codes to human-readable messages (#NAME?, #REF?, #DIV/0!) - - Populate `CellErrors` collection in result - - Suggest fixes for common errors - -3. **MCP Server Integration** - - Generate MCP tool definitions for `validate-formulas` action - - Add action to range tool schema - - Generate CLI command mapping - -4. **Documentation Updates** - - Add usage examples to tool descriptions - - Document namespace requirements for XA2 functions - - Add troubleshooting guide for formula issues - -## File Locations - -- **Result Types**: `src/ExcelMcp.Core/Models/ResultTypes.cs` (lines 857-1000+) -- **Interface**: `src/ExcelMcp.Core/Commands/Range/IRangeCommands.cs` (added lines 147-159) -- **Implementation**: `src/ExcelMcp.Core/Commands/Range/RangeCommands.FormulaValidation.cs` (new) -- **Validation Tests**: `tests/ExcelMcp.Core.Tests/.../RangeCommandsTests.FormulaValidation.cs` (new) -- **Detection Tests**: `tests/ExcelMcp.Core.Tests/.../RangeCommandsTests.SmartDetection.cs` (new) -- **IRM Tests**: `tests/ExcelMcp.Core.Tests/.../FileCommandsTests.IrmDetection.cs` (new) - -## Architecture Notes - -- **Validation is non-destructive** - ValidateFormulas does NOT apply formulas -- **Error detection is comprehensive** - Covers syntax, undefined functions, and references -- **Suggestions are actionable** - Specific fix recommendations (e.g., "use =XA2.GETVM3") -- **Batch API integrated** - All operations use batch.Execute() pattern -- **Test isolation** - Each test creates unique worksheet for isolation diff --git a/docs/archive/MCP-CORE-DELTA-ANALYSIS.md b/docs/archive/MCP-CORE-DELTA-ANALYSIS.md deleted file mode 100644 index 6d7283d7..00000000 --- a/docs/archive/MCP-CORE-DELTA-ANALYSIS.md +++ /dev/null @@ -1,268 +0,0 @@ -# MCP Tool ↔ Core Interface Delta Analysis - -> Generated 2026-02-07 — Comprehensive comparison of hand-written MCP tool files vs Core interface files -> Purpose: Identify what NEW attributes are needed on Core interfaces to auto-generate MCP tools. - ---- - -## 1. Existing Core Attributes (src/ExcelMcp.Core/Attributes/) - -| Attribute | Target | Properties | Purpose | -|-----------|--------|-----------|---------| -| `[ServiceCategory("cat", "Pascal")]` | Interface | `Category`, `PascalName` | Service routing category | -| `[McpTool("excel_xxx")]` | Interface, Method | `ToolName` | Maps interface → MCP tool name | -| `[NoSession]` | Interface | (none) | Marks as not requiring session | -| `[ServiceAction("name")]` | Method | `Action` | Overrides action name derivation | -| `[FromString("exposed")]` | Parameter | `ExposedName` | Exposes enum as string parameter | -| `[FileOrValue("File")]` | Parameter | `FileSuffix` | Creates dual param (value + file) | -| `[RequiredParameter]` | Parameter | (none) | Marks parameter as required | - -**Generator Model (ServiceInfoExtractor → ServiceInfo/MethodInfo/ParameterInfo) already extracts:** -- Category, CategoryPascal, McpToolName, NoSession -- MethodName, ActionName, ReturnType, Parameters, XmlDocSummary, HasBatchParameter -- Parameter: Name, TypeName, HasDefault, DefaultValue, IsFileOrValue, FileSuffix, IsFromString, ExposedName, IsRequired, IsEnum, XmlDocDescription - ---- - -## 2. MCP Tool Attributes NOT in Core (The Delta) - -### 2.1 Tool-Level Metadata (on `[McpServerTool]` / `[McpMeta]`) - -| MCP Attribute | Example | In Core? | Notes | -|---------------|---------|----------|-------| -| `Name = "excel_xxx"` | `powerquery` | ✅ YES | `[McpTool]` attribute already exists | -| `Title = "..."` | `"Excel Power Query Operations"` | ❌ **NO** | Human-readable title for MCP clients | -| `Destructive = true/false` | `true` (most), `false` (calc mode) | ❌ **NO** | Whether tool modifies data | -| `[McpMeta("category", "...")]` | `"data"`, `"analysis"`, `"query"` | ❌ **NO** | UI grouping category | -| `[McpMeta("requiresSession", bool)]` | `true` / `false` | ⚠️ PARTIAL | `[NoSession]` exists but is inverted; need value on most | -| `[McpMeta("fileFormat", ".xlsm")]` | VBA tool only | ❌ **NO** | Required file format constraint | - -### 2.2 Method-Level Summary Text (LLM Guidance) - -**Every MCP tool has a rich `` on the method** providing LLM-targeted guidance (best practices, workflows, related tools). The Core interface `` is usually shorter and more technical. These are **different** docs serving different audiences. - -| Tool | MCP Summary Lines | Core Summary Lines | Same? | -|------|-------------------|-------------------|-------| -| range | 18 lines (best practices, data format, named ranges, copy ops, number formats) | 14 lines (similar but shorter) | ⚠️ SIMILAR but MCP has more LLM-specific tips | -| powerquery | 18 lines (test-first workflow, datetime columns, M-code formatting, destinations) | 12 lines (similar core) | ⚠️ SIMILAR | -| chart | 24 lines (overlapping data avoidance, positioning, chart types, create options) | Not on interface | ❌ NO - only on MCP | -| file | 10 lines (workflow, session reuse, timeout) | 3 lines (minimal) | ❌ VERY DIFFERENT | -| table | 14 lines (best practices, data model workflow, DAX-backed tables, CSV append) | Not examined | ❌ DIFFERENT | - -**Verdict:** The MCP summary is LLM-specific guidance text. Core summaries describe the API technically. A new `[McpSummary("...")]` or `[LlmGuidance("...")]` attribute (or conventionally a separate doc block) would be needed if we want to auto-generate the MCP ``. - -### 2.3 Parameter Description Differences - -MCP `` docs are often **different** from Core `` docs — the MCP versions are LLM-optimized with examples and constraints: - -| Parameter | MCP Description | Core Description | -|-----------|----------------|-----------------| -| `sessionId` | `"Session ID from file 'open'. Required for all actions."` | Not present (batch param instead) | -| `sheetName` | `"Name of worksheet - REQUIRED for cell addresses, use empty string for named ranges"` | `"Name of the worksheet containing the range"` | -| `rangeAddress` | `"Cell range address (e.g., 'A1', 'A1:D10') or named range name (e.g., 'SalesData')"` | `"Cell range address (e.g., 'A1', 'A1:D10')"` | -| `values` | `"2D array of values - rows are outer array, columns are inner array (e.g., [[1,2,3],[4,5,6]])"` | `"2D array of values to set"` (shorter) | -| `path` | `"Full Windows path to Excel file. ASK USER for the path - do not guess"` | `"Path to the Excel file to validate"` | - -**Verdict:** MCP param docs need either a separate attribute or a convention for providing LLM-optimized descriptions. Could reuse the existing Core `` XML docs if they're made richer. - ---- - -## 3. Per-Tool Comparison Table - -### Legend -- **PP** = Pre-processing logic in method body -- **DV** = Non-null DefaultValue (not just `[DefaultValue(null)]`) -- **Meta** = Extra `[McpMeta]` beyond category + requiresSession - -| # | MCP Tool Name | Title | Destr. | Category | reqSession | Meta | Core Interface(s) | PP Logic | Notes | -|---|--------------|-------|--------|----------|-----------|------|-------------------|----------|-------| -| 1 | `file` | Excel File Operations | true | session | **false** | | `IFileCommands` (partial) | ✅ Extensive custom routing, path validation, timeout conversion | **Most custom** - manual Open/Close/Create/List, no RouteAction | -| 2 | `worksheet` | Excel Worksheet Operations | true | structure | true | | `ISheetCommands` | ✅ CopyToFile/MoveToFile use ForwardToServiceNoSession; param remapping (sheetName→oldName/sourceName) | Cross-file ops bypass session | -| 3 | `worksheet_style` | Excel Worksheet Style Operations | true | structure | true | | `ISheetStyleCommands` | Minimal (visibility?.ToString()) | Clean routing | -| 4 | `range` | Excel Range Operations | true | data | true | | `IRangeCommands` | ✅ `values as List>` cast | Type cast | -| 5 | `range_edit` | Excel Range Edit Operations | true | data | true | | `IRangeEditCommands` | ✅ `BuildFindOptions()`, `BuildReplaceOptions()` | Object construction from flat params | -| 6 | `range_format` | Excel Range Format Operations | true | data | true | | `IRangeFormatCommands` | Minimal remapping (e.g., `backgroundColor`→`fillColor`) | Param name remapping | -| 7 | `range_link` | Excel Range Link Operations | true | data | true | | `IRangeLinkCommands` | Minimal (`isLocked`→`locked`) | Param name remapping | -| 8 | `table` | Excel Table Operations | true | data | true | | `ITableCommands` | ✅ `ParseCsvToRows(csvData)`, `rows as List>`, multi-param reuse (hasHeaders→showTotals, styleName→totalFunction, newName→columnName) | Significant param overloading | -| 9 | `table_column` | Excel Table Column Operations | true | data | true | | `ITableColumnCommands` | ✅ `int.TryParse(columnPosition)`, `ParseJsonList()` / `DeserializeJson>()` conditional on action | JSON parsing + type conversion | -| 10 | `powerquery` | Excel Power Query Operations | true | query | true | | `IPowerQueryCommands` | ✅ `TimeSpan.FromSeconds(refreshTimeoutSeconds)`, `loadDestination?.ToString()`, `oldName` mapping for rename | TimeSpan conversion, enum→string | -| 11 | `connection` | Excel Data Connection Operations | true | query | true | | `IConnectionCommands` | Minimal (timeout: null) | Clean routing | -| 12 | `namedrange` | Excel Named Range Operations | true | data | true | | `INamedRangeCommands` | ✅ `value` doubles as `reference` for create/update | Param reuse | -| 13 | `pivottable` | Excel PivotTable Operations | true | analysis | true | | `IPivotTableCommands` | ✅ `tableName: sourceTableName ?? dataModelTableName` | Param merging | -| 14 | `pivottable_field` | Excel PivotTable Field Operations | true | analysis | true | | `IPivotTableFieldCommands` | ✅ `ParseJsonList(filterValues)`, `aggregationFunction?.ToString()`, `sortDirection?.ToString()`, `dateGroupingInterval?.ToString()` | JSON parsing + enum→string | -| 15 | `pivottable_calc` | Excel PivotTable Calc Operations | true | analysis | true | | `IPivotTableCalcCommands` | ✅ `memberType?.ToString()`, `fieldName` → `memberName` remapping | Enum→string, param remapping | -| 16 | `chart` | Excel Chart Operations | true | analysis | true | | `IChartCommands` | ✅ targetRange pre-processing: sets left/top to 0 when targetRange provided | Positioning logic | -| 17 | `chart_config` | Excel Chart Configuration | true | analysis | true | | `IChartConfigCommands` | Minimal (trendlineType→type remapping) | Clean routing, many params | -| 18 | `slicer` | Excel Slicer Operations | true | analysis | true | | `ISlicerCommands` | ✅ Auto-generate slicerName from field/column, `ParseJsonListOrSingle(selectedItems)` | Name generation + JSON parsing | -| 19 | `vba` | Excel VBA Operations | true | automation | true | `fileFormat=.xlsm` | `IVbaCommands` | ✅ `SplitCsvParameters(parameters)`, `moduleName` → `procedureName` remapping | CSV split, param remapping | -| 20 | `datamodel` | Excel Data Model Operations | true | analysis | true | | `IDataModelCommands` | ✅ `tableName` → `oldName` remapping for rename | Param remapping | -| 21 | `datamodel_relationship` | Excel Data Model Relationship Operations | true | analysis | true | | `IDataModelRelCommands` | Minimal | Clean routing | -| 22 | `conditionalformat` | Excel Conditional Formatting | true | structure | true | | `IConditionalFormattingCommands` | Minimal | Clean routing | -| 23 | `calculation_mode` | Excel Calculation Mode Control | **false** | settings | true | | `ICalculationCommands` | Minimal | **Only non-destructive tool** | - ---- - -## 4. Pre-Processing Patterns (What Can't Be Auto-Generated) - -These are the transformations in MCP tool method bodies that go beyond simple routing: - -### 4.1 Type Conversions - -| Pattern | Used In | What It Does | -|---------|---------|-------------| -| `TimeSpan.FromSeconds(int?)` | PowerQuery, File | Converts int seconds param → TimeSpan | -| `enum?.ToString()` | PowerQuery, PivotTableField, PivotTableCalc, WorksheetStyle | Converts nullable enum → string for Service routing | -| `int.TryParse(string)` | TableColumn | Converts string columnPosition → int? | -| `values as List>` | Range, Table | Cast from `List>` to `List>` | - -### 4.2 JSON Parsing - -| Pattern | Used In | What It Does | -|---------|---------|-------------| -| `ParseJsonList(json)` | PivotTableField, TableColumn | Parse `["a","b"]` → `List` | -| `ParseJsonListOrSingle(json)` | Slicer | Parse JSON array or treat as single value | -| `DeserializeJson(json)` | TableColumn | Parse `[{columnName, ascending}]` → `List` | -| `ParseCsvToRows(csv)` | Table | Parse multi-line CSV → `List>` | -| `SplitCsvParameters(csv)` | VBA | Split comma-separated string → string[] | - -### 4.3 Parameter Mapping / Overloading - -| Pattern | Used In | What It Does | -|---------|---------|-------------| -| Single param → multiple Core params | Table (`newName`→columnName, `styleName`→totalFunction), NamedRange (`value`→reference), Chart (`rangeAddress`=multiple uses) | MCP has fewer params than Core methods; same param serves multiple roles | -| Conditional param derivation | Worksheet (`sheetName`→oldName/sourceName), PowerQuery (`queryName`→oldName for rename), DataModel (`tableName`→oldName) | One MCP param maps to different Core params based on action | -| Auto-generation | Slicer (auto-generate slicerName from fieldName/columnName if not provided) | Business logic in MCP layer | -| Pre-processing | Chart (set left/top=0 when targetRange provided) | Derived defaults | - -### 4.4 Special Routing - -| Pattern | Used In | What It Does | -|---------|---------|-------------| -| ForwardToServiceNoSession | Worksheet (CopyToFile, MoveToFile) | Some actions bypass session routing | -| Completely custom (no RouteAction) | File | Manual switch on all actions, custom JSON responses | -| Object construction | RangeEdit (`BuildFindOptions`, `BuildReplaceOptions`) | Constructs complex objects from flat params | - ---- - -## 5. DefaultValue Summary - -Most parameters use `[DefaultValue(null)]`. Non-null defaults: - -| Tool | Parameter | Default | Core Default | -|------|-----------|---------|-------------| -| file | `save` | `false` | N/A (custom) | -| file | `show` | `false` | N/A (custom) | -| file | `timeoutSeconds` | `300` | N/A (custom) | -| table | `hasHeaders` | `true` | `true` (same) | -| table | `visibleOnly` | `false` | N/A | -| table_column | `ascending` | `true` | `true` (same) | -| slicer | `clearFirst` | `true` | `true` (same) | -| datamodel_relationship | `active` | `true` | `true` (same) | -| calculation_mode | (Destructive) | `false` | N/A | - ---- - -## 6. New Attributes Needed for Auto-Generation - -Based on the delta analysis, these new attributes would be required on Core interfaces to fully auto-generate MCP tools: - -### 6.1 Must-Have (No Workaround) - -| New Attribute | Target | Example | Why Needed | -|---------------|--------|---------|-----------| -| `[McpTitle("...")]` | Method | `[McpTitle("Excel Power Query Operations")]` | Tool title for MCP clients. No current equivalent. | -| `[Destructive(bool)]` | Method or Interface | `[Destructive(true)]` | MCP SDK requires this on `[McpServerTool]`. Default true but calc_mode is false. | -| `[McpCategory("...")]` | Interface | `[McpCategory("analysis")]` | UI grouping. Different from ServiceCategory (routing). Values: data, analysis, query, structure, session, automation, settings | -| `[McpMeta("key", value)]` | Interface or Method | `[McpMeta("fileFormat", ".xlsm")]` | Arbitrary metadata (only VBA uses fileFormat currently) | - -### 6.2 Should-Have (For Quality) - -| New Attribute | Target | Example | Why Needed | -|---------------|--------|---------|-----------| -| `[McpSummary("...")]` or separate doc file | Interface/Method | Multi-line LLM guidance text | MCP method summary is different from Core interface summary. Could also use a convention (e.g., `/// `) or external .md files. | -| `[McpParamDescription("...")]` | Parameter | LLM-optimized param description | MCP param descriptions differ from Core. Alternative: make Core `` docs richer. | -| `[McpParamName("...")]` | Parameter | `[McpParamName("sessionId")]` | MCP exposes `sessionId` but Core uses `IExcelBatch batch`. Need to know the MCP-facing name. Generator already handles batch→sessionId. | -| `[DefaultValue(val)]` on Core params | Parameter | Already exists via C# default values | Generator already reads these. ✅ | - -### 6.3 Pre-Processing Attributes (For Complex Logic) - -These patterns are harder to express as attributes and may need **code hooks** or **transform attributes**: - -| Pattern | Proposed Solution | Complexity | -|---------|------------------|-----------| -| `TimeSpan.FromSeconds()` | `[TimeSpanFromSeconds]` on the Core param (already `TimeSpan timeout` in Core) | Low - generator can emit conversion | -| `enum?.ToString()` | Already handled by `[FromString]` attribute | ✅ Done | -| `ParseJsonList` | `[JsonList]` attribute on `List` param | Medium - needs to know the MCP type is `string` but Core type is `List` | -| `ParseJsonListOrSingle` | `[JsonListOrSingle]` attribute variant | Medium | -| `DeserializeJson` | `[JsonDeserialize]` attribute | Medium | -| `ParseCsvToRows` | `[CsvToRows]` attribute | Medium | -| `SplitCsvParameters` | `[CsvSplit]` attribute | Low | -| `BuildFindOptions/BuildReplaceOptions` | `[ExpandToObject]` or flatten params in Core | Hard - would need to flatten Core method signatures | -| Param overloading (same MCP param → multiple Core params) | `[McpParamAlias("mcpName")]` on each Core param that uses same MCP input | Medium | -| Auto-generation logic (slicer name) | Custom hook / `[AutoGenerate]` | Hard | -| Conditional logic (chart targetRange → left/top=0) | Custom hook | Hard | -| No-session routing (CopyToFile) | Already signaled by no `IExcelBatch` param in Core | ✅ Done | -| Fully custom tools (file) | Cannot auto-generate. Keep as hand-written. | N/A | - ---- - -## 7. Recommendations - -### Tier 1: Add These Attributes Now (Easy wins, unblock generation for 15+ tools) - -```csharp -// On interface or method - new attributes -[McpTitle("Excel Power Query Operations")] // → McpServerTool.Title -[Destructive(true)] // → McpServerTool.Destructive -[McpCategory("query")] // → McpMeta("category", ...) - -// These already exist and work: -[McpTool("powerquery")] // → McpServerTool.Name -[NoSession] // → McpMeta("requiresSession", false) -``` - -### Tier 2: Enhance Param Transforms (Medium effort, covers 80% of pre-processing) - -```csharp -// New parameter-level attributes -[TimeSpanSeconds] // int → TimeSpan conversion -[JsonList] // string → List via ParseJsonList -[JsonListOrSingle] // string → List via ParseJsonListOrSingle -[JsonDeserialize] // string → T via DeserializeJson -[CsvRows] // string → List> via ParseCsvToRows -[CsvSplit] // string → string[] via SplitCsvParameters -[McpParamAlias("mcpParamName")] // Multiple Core params sharing one MCP param -``` - -### Tier 3: Keep Hand-Written (Complex custom logic) - -These tools have too much custom logic to auto-generate and should remain hand-written: - -1. **`file`** - Completely custom routing, no RouteAction, custom JSON responses -2. **`worksheet`** - CopyToFile/MoveToFile no-session routing -3. **`table`** - Heavy param overloading (hasHeaders→showTotals, styleName→totalFunction) -4. **`chart`** - targetRange pre-processing logic -5. **`slicer`** - Auto-name generation - -The remaining **~17 tools** could be auto-generated with Tier 1 + Tier 2 attributes. - ---- - -## 8. Summary Statistics - -| Metric | Count | -|--------|-------| -| Total MCP tools | 23 (including file) | -| Tools using `[McpServerToolType]` | 23 | -| Tools with `Destructive = true` | 22 | -| Tools with `Destructive = false` | 1 (calculation_mode) | -| Unique `[McpMeta("category")]` values | 7 (data, analysis, query, structure, session, automation, settings) | -| Tools with `requiresSession = true` | 21 | -| Tools with `requiresSession = false` | 2 (file, file has it explicit) | -| Tools with pre-processing logic | 15 | -| Tools with clean/minimal routing | 8 | -| Tools likely auto-generatable (Tier 1+2) | ~17-18 | -| Tools requiring hand-written code | ~5 | -| New Core attributes needed (Tier 1) | 3 (`McpTitle`, `Destructive`, `McpCategory`) | -| New Core attributes needed (Tier 2) | 6-7 (param transform attributes) | -| Existing attributes sufficient | 7 (`ServiceCategory`, `McpTool`, `NoSession`, `ServiceAction`, `FromString`, `FileOrValue`, `RequiredParameter`) | diff --git a/docs/archive/README.md b/docs/archive/README.md deleted file mode 100644 index c92d4592..00000000 --- a/docs/archive/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# Archive - -This directory contains historical research documents and feature-branch implementation notes that are no longer actively maintained. - -These files are preserved for reference but do not reflect the current state of the project. - -| File | Description | -|------|-------------| -| `AGENT-SKILLS-RESEARCH.md` | Research on AI agent skills systems across VS Code, Claude Code, and other assistants | -| `API-COMPARISON-REPORT.md` | API surface comparison between branches during MCP daemon unification work | -| `MCP-CORE-DELTA-ANALYSIS.md` | Delta analysis of MCP tool files vs Core interface files (used for source generator design) | -| `IMPLEMENTATION_STATUS.md` | Feature-branch implementation notes for Formula Validation and related range improvements | diff --git a/examples/README.md b/examples/README.md index f2dcb0b0..324bff3f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,6 +1,6 @@ -# ExcelMcp CLI Examples +# PptMcp CLI Examples -This directory contains example scripts demonstrating ExcelMcp CLI features. +This directory contains example scripts demonstrating PptMcp CLI features. ## Session Mode Demo @@ -8,8 +8,8 @@ The session mode demo shows how to use sessions for high-performance multi-opera ### Requirements -- Windows with Excel installed -- ExcelMcp installed (`dotnet tool install --global Sbroenne.ExcelMcp.McpServer`) +- Windows with PowerPoint installed +- PptMcp installed (`dotnet tool install --global PptMcp.McpServer`) ### Running the Demo @@ -25,11 +25,11 @@ The session mode demo shows how to use sessions for high-performance multi-opera ### What the Demo Does -1. Creates a test workbook (`test-session.xlsx`) +1. Creates a test presentation (`test-session.pptx`) 2. Opens a session and captures the session ID -3. Performs multiple operations using the same Excel instance: - - Creates 3 worksheets (Sales, Customers, Products) - - Lists worksheets +3. Performs multiple operations using the same PowerPoint instance: + - Creates 3 slides (Sales, Customers, Products) + - Lists slides - Lists Power Queries 4. Lists active sessions 5. Closes the session with `--save` (saves all changes) @@ -38,19 +38,19 @@ The session mode demo shows how to use sessions for high-performance multi-opera ### Expected Performance Session mode is **75-90% faster** than running individual commands because: -- Only one Excel instance is opened +- Only one PowerPoint instance is opened - No file open/close overhead between operations - All changes committed atomically ### Cleanup ```powershell -rm test-session.xlsx +rm test-session.pptx ``` Or in PowerShell: ```powershell -Remove-Item test-session.xlsx +Remove-Item test-session.pptx ``` ## Use Cases @@ -59,4 +59,4 @@ Session mode is ideal for: - **RPA workflows** - Automated report generation - **Data pipelines** - ETL operations with multiple steps - **Testing** - Setting up test data across multiple sheets -- **Bulk operations** - Making many changes to a workbook +- **Bulk operations** - Making many changes to a presentation diff --git a/examples/mcp-configs/README.md b/examples/mcp-configs/README.md index be5e9dc1..5d1de6ff 100644 --- a/examples/mcp-configs/README.md +++ b/examples/mcp-configs/README.md @@ -4,10 +4,10 @@ This directory contains ready-to-use MCP configuration files for various AI codi ## Quick Setup Guide -### 1. Install ExcelMcp MCP Server +### 1. Install PptMcp MCP Server ```powershell -dotnet tool install --global Sbroenne.ExcelMcp.McpServer +dotnet tool install --global PptMcp.McpServer ``` ### 2. Choose Your Client and Copy the Config @@ -27,12 +27,12 @@ Select the configuration file for your AI assistant and follow the instructions 1. Open File Explorer and navigate to: `%APPDATA%\Claude\` 2. If `claude_desktop_config.json` doesn't exist, create it 3. Copy the contents of `claude-desktop-config.json` from this folder -4. If you already have a config file, merge the `excel-mcp` server entry into your existing `mcpServers` section +4. If you already have a config file, merge the `ppt-mcp` server entry into your existing `mcpServers` section 5. Restart Claude Desktop **Test it:** ``` -Create an Excel file called "test.xlsx" +Create a PowerPoint file called "test.pptx" ``` --- @@ -51,12 +51,12 @@ Create an Excel file called "test.xlsx" 2. Search for "MCP" in settings 3. Click "Edit in settings.json" or manually create the config file at the location above 4. Copy the contents of `cursor-mcp-config.json` from this folder -5. If you already have a config file, merge the `excel-mcp` server entry +5. If you already have a config file, merge the `ppt-mcp` server entry 6. Restart Cursor **Test it:** ``` -Create an Excel file called "test.xlsx" +Create a PowerPoint file called "test.pptx" ``` --- @@ -79,7 +79,7 @@ Create an Excel file called "test.xlsx" **Test it:** ``` -Create an Excel file called "test.xlsx" +Create a PowerPoint file called "test.pptx" ``` --- @@ -101,7 +101,7 @@ Create an Excel file called "test.xlsx" **Test it:** ``` -Create an Excel file called "test.xlsx" +Create a PowerPoint file called "test.pptx" ``` --- @@ -115,7 +115,7 @@ Create an Excel file called "test.xlsx" **Setup Steps:** **Option A: Use VS Code Extension (Recommended)** -1. Install the [Excel MCP VS Code Extension](https://marketplace.visualstudio.com/items?itemName=sbroenne.excel-mcp) +1. Install the [PowerPoint MCP VS Code Extension](https://marketplace.visualstudio.com/items?itemName=trsdn.ppt-mcp) 2. Configuration is automatic! **Option B: Manual Configuration** @@ -125,7 +125,7 @@ Create an Excel file called "test.xlsx" **Test it:** ``` -Create an Excel file called "test.xlsx" +Create a PowerPoint file called "test.pptx" ``` --- @@ -136,7 +136,7 @@ Create an Excel file called "test.xlsx" 1. **Verify installation:** ```powershell - dotnet tool list --global | Select-String "ExcelMcp" + dotnet tool list --global | Select-String "PptMcp" ``` 2. **Check .NET is installed:** @@ -147,24 +147,24 @@ Create an Excel file called "test.xlsx" 3. **Reinstall if needed:** ```powershell - dotnet tool uninstall --global Sbroenne.ExcelMcp.McpServer - dotnet tool install --global Sbroenne.ExcelMcp.McpServer + dotnet tool uninstall --global PptMcp.McpServer + dotnet tool install --global PptMcp.McpServer ``` -### Excel Not Found +### PowerPoint Not Found -- Ensure Microsoft Excel Desktop (2016+) is installed -- ExcelMcp requires Windows OS with Excel installed +- Ensure Microsoft PowerPoint Desktop (2016+) is installed +- PptMcp requires Windows OS with PowerPoint installed ### Permission Issues -- Close all Excel windows before running ExcelMcp -- Ensure your user account has Excel access +- Close all PowerPoint windows before running PptMcp +- Ensure your user account has PowerPoint access ### Still Having Issues? - Check the [main installation guide](../../docs/INSTALLATION.md) -- Report issues on [GitHub](https://github.com/sbroenne/mcp-server-excel/issues) +- Report issues on [GitHub](https://github.com/trsdn/mcp-server-ppt/issues) --- @@ -182,5 +182,5 @@ If you work with multiple workspaces, you can: - **[Main README](../../README.md)** - Feature overview and examples - **[Installation Guide](../../docs/INSTALLATION.md)** - Comprehensive setup instructions -- **[MCP Server README](../../src/ExcelMcp.McpServer/README.md)** - Tool documentation -- **[GitHub Repository](https://github.com/sbroenne/mcp-server-excel)** - Source code and issues +- **[MCP Server README](../../src/PptMcp.McpServer/README.md)** - Tool documentation +- **[GitHub Repository](https://github.com/trsdn/mcp-server-ppt)** - Source code and issues diff --git a/examples/mcp-configs/claude-desktop-config.json b/examples/mcp-configs/claude-desktop-config.json index 79dae0f7..1cb28971 100644 --- a/examples/mcp-configs/claude-desktop-config.json +++ b/examples/mcp-configs/claude-desktop-config.json @@ -1,7 +1,7 @@ { "mcpServers": { - "excel-mcp": { - "command": "mcp-excel", + "ppt-mcp": { + "command": "mcp-ppt", "args": [], "env": {} } diff --git a/examples/mcp-configs/cline-mcp-config.json b/examples/mcp-configs/cline-mcp-config.json index 79dae0f7..1cb28971 100644 --- a/examples/mcp-configs/cline-mcp-config.json +++ b/examples/mcp-configs/cline-mcp-config.json @@ -1,7 +1,7 @@ { "mcpServers": { - "excel-mcp": { - "command": "mcp-excel", + "ppt-mcp": { + "command": "mcp-ppt", "args": [], "env": {} } diff --git a/examples/mcp-configs/cursor-mcp-config.json b/examples/mcp-configs/cursor-mcp-config.json index 79dae0f7..1cb28971 100644 --- a/examples/mcp-configs/cursor-mcp-config.json +++ b/examples/mcp-configs/cursor-mcp-config.json @@ -1,7 +1,7 @@ { "mcpServers": { - "excel-mcp": { - "command": "mcp-excel", + "ppt-mcp": { + "command": "mcp-ppt", "args": [], "env": {} } diff --git a/examples/mcp-configs/vscode-mcp-config.json b/examples/mcp-configs/vscode-mcp-config.json index 36da7737..e1b486ca 100644 --- a/examples/mcp-configs/vscode-mcp-config.json +++ b/examples/mcp-configs/vscode-mcp-config.json @@ -1,7 +1,7 @@ { "servers": { - "excel-mcp": { - "command": "mcp-excel" + "ppt-mcp": { + "command": "mcp-ppt" } } } diff --git a/examples/mcp-configs/windsurf-mcp-config.json b/examples/mcp-configs/windsurf-mcp-config.json index 79dae0f7..1cb28971 100644 --- a/examples/mcp-configs/windsurf-mcp-config.json +++ b/examples/mcp-configs/windsurf-mcp-config.json @@ -1,7 +1,7 @@ { "mcpServers": { - "excel-mcp": { - "command": "mcp-excel", + "ppt-mcp": { + "command": "mcp-ppt", "args": [], "env": {} } diff --git a/gh-pages/.bundle/config b/gh-pages/.bundle/config deleted file mode 100644 index 23692288..00000000 --- a/gh-pages/.bundle/config +++ /dev/null @@ -1,2 +0,0 @@ ---- -BUNDLE_PATH: "vendor/bundle" diff --git a/gh-pages/.gitignore b/gh-pages/.gitignore deleted file mode 100644 index b2d98b71..00000000 --- a/gh-pages/.gitignore +++ /dev/null @@ -1,18 +0,0 @@ -# Jekyll build output -_site/ - -# Ruby/Bundler -vendor/ -.bundle/ -Gemfile.lock - -# Generated content files (copied by build.sh from project root) -_includes/features.md -_includes/changelog.md -_includes/installation.md -_includes/contributing.md -_includes/security.md -_includes/privacy.md -_includes/mcp-server.md -_includes/cli.md -_includes/skills.md diff --git a/gh-pages/2049c837880704706739672b0ca752f9.txt b/gh-pages/2049c837880704706739672b0ca752f9.txt deleted file mode 100644 index a75f86f1..00000000 --- a/gh-pages/2049c837880704706739672b0ca752f9.txt +++ /dev/null @@ -1 +0,0 @@ -2049c837880704706739672b0ca752f9 diff --git a/gh-pages/404.md b/gh-pages/404.md deleted file mode 100644 index 17ccf2f7..00000000 --- a/gh-pages/404.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -layout: default -title: "Page Not Found" -permalink: /404.html -sitemap: false ---- - -
- -# 404 - Page Not Found - -The page you're looking for doesn't exist or has been moved. - - - -## Quick Links - -- [Features](/features/) — All 25 tools and 225 operations -- [Installation](/installation/) — Setup guides for VS Code, Claude, and CLI -- [Changelog](/changelog/) — Release notes and version history -- [GitHub Repository](https://github.com/sbroenne/mcp-server-excel) — Source code and issues - -
diff --git a/gh-pages/BingSiteAuth.xml b/gh-pages/BingSiteAuth.xml deleted file mode 100644 index a3bda938..00000000 --- a/gh-pages/BingSiteAuth.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - C043BE1CE650BCF122B8139C77889186 - \ No newline at end of file diff --git a/gh-pages/CNAME b/gh-pages/CNAME deleted file mode 100644 index 23424e7d..00000000 --- a/gh-pages/CNAME +++ /dev/null @@ -1 +0,0 @@ -excelmcpserver.dev diff --git a/gh-pages/Gemfile b/gh-pages/Gemfile deleted file mode 100644 index 602b5ef1..00000000 --- a/gh-pages/Gemfile +++ /dev/null @@ -1,9 +0,0 @@ -# Gemfile for local Jekyll development -# To run locally: bundle install && bundle exec jekyll serve - -source "https://rubygems.org" - -gem "github-pages", group: :jekyll_plugins -gem "jekyll-sitemap" -gem "jekyll-seo-tag" -gem "jekyll-feed" diff --git a/gh-pages/Gemfile.lock b/gh-pages/Gemfile.lock deleted file mode 100644 index e3078562..00000000 --- a/gh-pages/Gemfile.lock +++ /dev/null @@ -1,287 +0,0 @@ -GEM - remote: https://rubygems.org/ - specs: - activesupport (8.1.1) - base64 - bigdecimal - concurrent-ruby (~> 1.0, >= 1.3.1) - connection_pool (>= 2.2.5) - drb - i18n (>= 1.6, < 2) - json - logger (>= 1.4.2) - minitest (>= 5.1) - securerandom (>= 0.3) - tzinfo (~> 2.0, >= 2.0.5) - uri (>= 0.13.1) - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) - base64 (0.3.0) - bigdecimal (3.3.1) - coffee-script (2.4.1) - coffee-script-source - execjs - coffee-script-source (1.12.2) - colorator (1.1.0) - commonmarker (0.23.12) - concurrent-ruby (1.3.5) - connection_pool (2.5.4) - csv (3.3.5) - dnsruby (1.73.1) - base64 (>= 0.2) - logger (~> 1.6) - simpleidn (~> 0.2.1) - drb (2.2.3) - em-websocket (0.5.3) - eventmachine (>= 0.12.9) - http_parser.rb (~> 0) - ethon (0.15.0) - ffi (>= 1.15.0) - eventmachine (1.2.7) - execjs (2.10.0) - faraday (2.14.1) - faraday-net_http (>= 2.0, < 3.5) - json - logger - faraday-net_http (3.4.2) - net-http (~> 0.5) - ffi (1.17.2-aarch64-linux-gnu) - ffi (1.17.2-x86_64-linux-gnu) - forwardable-extended (2.6.0) - gemoji (4.1.0) - github-pages (232) - github-pages-health-check (= 1.18.2) - jekyll (= 3.10.0) - jekyll-avatar (= 0.8.0) - jekyll-coffeescript (= 1.2.2) - jekyll-commonmark-ghpages (= 0.5.1) - jekyll-default-layout (= 0.1.5) - jekyll-feed (= 0.17.0) - jekyll-gist (= 1.5.0) - jekyll-github-metadata (= 2.16.1) - jekyll-include-cache (= 0.2.1) - jekyll-mentions (= 1.6.0) - jekyll-optional-front-matter (= 0.3.2) - jekyll-paginate (= 1.1.0) - jekyll-readme-index (= 0.3.0) - jekyll-redirect-from (= 0.16.0) - jekyll-relative-links (= 0.6.1) - jekyll-remote-theme (= 0.4.3) - jekyll-sass-converter (= 1.5.2) - jekyll-seo-tag (= 2.8.0) - jekyll-sitemap (= 1.4.0) - jekyll-swiss (= 1.0.0) - jekyll-theme-architect (= 0.2.0) - jekyll-theme-cayman (= 0.2.0) - jekyll-theme-dinky (= 0.2.0) - jekyll-theme-hacker (= 0.2.0) - jekyll-theme-leap-day (= 0.2.0) - jekyll-theme-merlot (= 0.2.0) - jekyll-theme-midnight (= 0.2.0) - jekyll-theme-minimal (= 0.2.0) - jekyll-theme-modernist (= 0.2.0) - jekyll-theme-primer (= 0.6.0) - jekyll-theme-slate (= 0.2.0) - jekyll-theme-tactile (= 0.2.0) - jekyll-theme-time-machine (= 0.2.0) - jekyll-titles-from-headings (= 0.5.3) - jemoji (= 0.13.0) - kramdown (= 2.4.0) - kramdown-parser-gfm (= 1.1.0) - liquid (= 4.0.4) - mercenary (~> 0.3) - minima (= 2.5.1) - nokogiri (>= 1.16.2, < 2.0) - rouge (= 3.30.0) - terminal-table (~> 1.4) - webrick (~> 1.8) - github-pages-health-check (1.18.2) - addressable (~> 2.3) - dnsruby (~> 1.60) - octokit (>= 4, < 8) - public_suffix (>= 3.0, < 6.0) - typhoeus (~> 1.3) - html-pipeline (2.14.3) - activesupport (>= 2) - nokogiri (>= 1.4) - http_parser.rb (0.8.0) - i18n (1.14.7) - concurrent-ruby (~> 1.0) - jekyll (3.10.0) - addressable (~> 2.4) - colorator (~> 1.0) - csv (~> 3.0) - em-websocket (~> 0.5) - i18n (>= 0.7, < 2) - jekyll-sass-converter (~> 1.0) - jekyll-watch (~> 2.0) - kramdown (>= 1.17, < 3) - liquid (~> 4.0) - mercenary (~> 0.3.3) - pathutil (~> 0.9) - rouge (>= 1.7, < 4) - safe_yaml (~> 1.0) - webrick (>= 1.0) - jekyll-avatar (0.8.0) - jekyll (>= 3.0, < 5.0) - jekyll-coffeescript (1.2.2) - coffee-script (~> 2.2) - coffee-script-source (~> 1.12) - jekyll-commonmark (1.4.0) - commonmarker (~> 0.22) - jekyll-commonmark-ghpages (0.5.1) - commonmarker (>= 0.23.7, < 1.1.0) - jekyll (>= 3.9, < 4.0) - jekyll-commonmark (~> 1.4.0) - rouge (>= 2.0, < 5.0) - jekyll-default-layout (0.1.5) - jekyll (>= 3.0, < 5.0) - jekyll-feed (0.17.0) - jekyll (>= 3.7, < 5.0) - jekyll-gist (1.5.0) - octokit (~> 4.2) - jekyll-github-metadata (2.16.1) - jekyll (>= 3.4, < 5.0) - octokit (>= 4, < 7, != 4.4.0) - jekyll-include-cache (0.2.1) - jekyll (>= 3.7, < 5.0) - jekyll-mentions (1.6.0) - html-pipeline (~> 2.3) - jekyll (>= 3.7, < 5.0) - jekyll-optional-front-matter (0.3.2) - jekyll (>= 3.0, < 5.0) - jekyll-paginate (1.1.0) - jekyll-readme-index (0.3.0) - jekyll (>= 3.0, < 5.0) - jekyll-redirect-from (0.16.0) - jekyll (>= 3.3, < 5.0) - jekyll-relative-links (0.6.1) - jekyll (>= 3.3, < 5.0) - jekyll-remote-theme (0.4.3) - addressable (~> 2.0) - jekyll (>= 3.5, < 5.0) - jekyll-sass-converter (>= 1.0, <= 3.0.0, != 2.0.0) - rubyzip (>= 1.3.0, < 3.0) - jekyll-sass-converter (1.5.2) - sass (~> 3.4) - jekyll-seo-tag (2.8.0) - jekyll (>= 3.8, < 5.0) - jekyll-sitemap (1.4.0) - jekyll (>= 3.7, < 5.0) - jekyll-swiss (1.0.0) - jekyll-theme-architect (0.2.0) - jekyll (> 3.5, < 5.0) - jekyll-seo-tag (~> 2.0) - jekyll-theme-cayman (0.2.0) - jekyll (> 3.5, < 5.0) - jekyll-seo-tag (~> 2.0) - jekyll-theme-dinky (0.2.0) - jekyll (> 3.5, < 5.0) - jekyll-seo-tag (~> 2.0) - jekyll-theme-hacker (0.2.0) - jekyll (> 3.5, < 5.0) - jekyll-seo-tag (~> 2.0) - jekyll-theme-leap-day (0.2.0) - jekyll (> 3.5, < 5.0) - jekyll-seo-tag (~> 2.0) - jekyll-theme-merlot (0.2.0) - jekyll (> 3.5, < 5.0) - jekyll-seo-tag (~> 2.0) - jekyll-theme-midnight (0.2.0) - jekyll (> 3.5, < 5.0) - jekyll-seo-tag (~> 2.0) - jekyll-theme-minimal (0.2.0) - jekyll (> 3.5, < 5.0) - jekyll-seo-tag (~> 2.0) - jekyll-theme-modernist (0.2.0) - jekyll (> 3.5, < 5.0) - jekyll-seo-tag (~> 2.0) - jekyll-theme-primer (0.6.0) - jekyll (> 3.5, < 5.0) - jekyll-github-metadata (~> 2.9) - jekyll-seo-tag (~> 2.0) - jekyll-theme-slate (0.2.0) - jekyll (> 3.5, < 5.0) - jekyll-seo-tag (~> 2.0) - jekyll-theme-tactile (0.2.0) - jekyll (> 3.5, < 5.0) - jekyll-seo-tag (~> 2.0) - jekyll-theme-time-machine (0.2.0) - jekyll (> 3.5, < 5.0) - jekyll-seo-tag (~> 2.0) - jekyll-titles-from-headings (0.5.3) - jekyll (>= 3.3, < 5.0) - jekyll-watch (2.2.1) - listen (~> 3.0) - jemoji (0.13.0) - gemoji (>= 3, < 5) - html-pipeline (~> 2.2) - jekyll (>= 3.0, < 5.0) - json (2.18.1) - kramdown (2.4.0) - rexml - kramdown-parser-gfm (1.1.0) - kramdown (~> 2.0) - liquid (4.0.4) - listen (3.9.0) - rb-fsevent (~> 0.10, >= 0.10.3) - rb-inotify (~> 0.9, >= 0.9.10) - logger (1.7.0) - mercenary (0.3.6) - minima (2.5.1) - jekyll (>= 3.5, < 5.0) - jekyll-feed (~> 0.9) - jekyll-seo-tag (~> 2.1) - minitest (5.26.1) - net-http (0.9.1) - uri (>= 0.11.1) - nokogiri (1.19.1-aarch64-linux-gnu) - racc (~> 1.4) - nokogiri (1.19.1-x86_64-linux-gnu) - racc (~> 1.4) - octokit (4.25.1) - faraday (>= 1, < 3) - sawyer (~> 0.9) - pathutil (0.16.2) - forwardable-extended (~> 2.6) - public_suffix (5.1.1) - racc (1.8.1) - rb-fsevent (0.11.2) - rb-inotify (0.11.1) - ffi (~> 1.0) - rexml (3.4.4) - rouge (3.30.0) - rubyzip (2.4.1) - safe_yaml (1.0.5) - sass (3.7.4) - sass-listen (~> 4.0.0) - sass-listen (4.0.0) - rb-fsevent (~> 0.9, >= 0.9.4) - rb-inotify (~> 0.9, >= 0.9.7) - sawyer (0.9.3) - addressable (>= 2.3.5) - faraday (>= 0.17.3, < 3) - securerandom (0.4.1) - simpleidn (0.2.3) - terminal-table (1.8.0) - unicode-display_width (~> 1.1, >= 1.1.1) - typhoeus (1.5.0) - ethon (>= 0.9.0, < 0.16.0) - tzinfo (2.0.6) - concurrent-ruby (~> 1.0) - unicode-display_width (1.8.0) - uri (1.1.1) - webrick (1.9.1) - -PLATFORMS - aarch64-linux-gnu - x86_64-linux - -DEPENDENCIES - github-pages - jekyll-feed - jekyll-seo-tag - jekyll-sitemap - -BUNDLED WITH - 2.4.20 diff --git a/gh-pages/_config.yml b/gh-pages/_config.yml deleted file mode 100644 index ba1fb714..00000000 --- a/gh-pages/_config.yml +++ /dev/null @@ -1,56 +0,0 @@ -# Jekyll configuration for GitHub Pages -# https://jekyllrb.com/docs/configuration/ - -# Site settings -title: "Excel MCP Server" -description: "AI-powered Excel automation through conversational AI" -url: "https://excelmcpserver.dev" -baseurl: "" -repository: "sbroenne/mcp-server-excel" - -# Theme -theme: jekyll-theme-cayman - -# Plugins -plugins: - - jekyll-sitemap - - jekyll-seo-tag - - jekyll-feed - -# Feed settings -feed: - path: feed.xml - -# SEO settings -author: "Stefan Broenne" -twitter: - card: summary_large_image -social: - name: Stefan Broenne - links: - - https://github.com/sbroenne - - https://www.youtube.com/channel/UCq1ojGTy4-aZz8_CmEPp2aw - -logo: /assets/images/icon.png - -# Additional SEO settings -defaults: - - scope: - path: "" - values: - image: /assets/images/icon.png - -# Build settings -markdown: kramdown -kramdown: - input: GFM - syntax_highlighter: rouge - -# Exclude files from processing -exclude: - - README.md - - LICENSE - - Gemfile - - Gemfile.lock - - vendor - - .bundle diff --git a/gh-pages/_includes/README.md b/gh-pages/_includes/README.md deleted file mode 100644 index 994cdf74..00000000 --- a/gh-pages/_includes/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# Jekyll Includes Directory - -This directory contains include files for Jekyll pages. - -**Note:** Some files in this directory are **auto-generated** by `build.sh`: -- `features.md` - Copied from `/FEATURES.md` -- `changelog.md` - Copied from `/CHANGELOG.md` (centralized changelog) -- `installation.md` - Copied from `/docs/INSTALLATION.md` -- `mcp-server.md` - Copied from `/src/ExcelMcp.McpServer/README.md` -- `cli.md` - Copied from `/src/ExcelMcp.CLI/README.md` -- `skills.md` - Copied from `/skills/README.md` - -These generated files are listed in `.gitignore` and should not be committed. -The `build.sh` script copies them before each Jekyll build to ensure the site -always reflects the latest content from the source files. diff --git a/gh-pages/_layouts/default.html b/gh-pages/_layouts/default.html deleted file mode 100644 index 7dba3131..00000000 --- a/gh-pages/_layouts/default.html +++ /dev/null @@ -1,82 +0,0 @@ - - - - - - - - - - - - - - - - {% seo %} - - - {% if page.keywords %}{% endif %} - - - - - - - - - - - - - - - - - - -
- {{ content }} - - {% if site.github.private != true and site.github.license %} - - {% endif %} -
- - - - diff --git a/gh-pages/assets/css/style.css b/gh-pages/assets/css/style.css deleted file mode 100644 index 870bdb68..00000000 --- a/gh-pages/assets/css/style.css +++ /dev/null @@ -1,443 +0,0 @@ -/* Excel MCP Server - GitHub Pages Styling */ - -:root { - --primary-color: #217346; - --secondary-color: #107c41; - --accent-color: #33a85c; - --background: #ffffff; - --text-primary: #1f1f1f; - --text-secondary: #605e5c; - --code-background: #f5f5f5; - --border-color: #e1e1e1; - --hero-gradient: linear-gradient(135deg, #217346 0%, #33a85c 100%); -} - -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; - line-height: 1.6; - color: var(--text-primary); - background: var(--background); - font-size: 16px; -} - -/* Site Title (header navigation) - styled like H1 but semantic div */ -.site-title { - font-size: 2em; - font-weight: 600; - margin-bottom: 16px; -} - -.site-title a { - color: var(--primary-color); - text-decoration: none; -} - -.site-title a:hover { - text-decoration: underline; -} - -.container { - max-width: 1200px; - margin: 0 auto; - padding: 0 24px; -} - -.content-section { - background: var(--background); - padding: 16px 24px 48px; - min-height: 400px; -} - -/* Hero Section */ -.hero { - background: var(--hero-gradient); - color: white; - padding: 32px 24px 24px; - text-align: center; - margin-bottom: 16px; - box-shadow: 0 4px 12px rgba(0,0,0,0.1); -} - -.hero-content { - max-width: 900px; - margin: 0 auto; -} - -.hero-icon { - width: 64px; - height: 64px; - margin: 0 auto 12px; - border-radius: 14px; - box-shadow: 0 8px 24px rgba(0,0,0,0.3); - background: white; - padding: 8px; - animation: fadeInScale 0.6s ease-out; - border: 3px solid rgba(255,255,255,0.2); - display: block; -} - -.hero-title { - font-size: 2.2rem; - font-weight: 700; - margin-bottom: 6px; - line-height: 1.2; - color: #ffffff; - margin-top: 0 !important; -} - -.hero-subtitle { - font-size: 1.2rem; - font-weight: 400; - color: #ffffff; - opacity: 0.95; - margin-bottom: 10px; -} - -.hero-description { - font-size: 1rem; - font-weight: 300; - color: #ffffff; - opacity: 0.9; - max-width: 700px; - margin: 0 auto; -} - -/* Badges Section */ -.badges-section { - background: #f8f9fa; - padding: 20px 24px; - border-bottom: 1px solid var(--border-color); -} - -.badges-section .container { - display: flex; - flex-direction: column; - gap: 8px; -} - -.hero-badges { - margin: 0; - display: flex; - gap: 8px; - justify-content: center; - flex-wrap: wrap; - align-items: center; -} - -.hero-badges img { - height: 20px; - transition: transform 0.2s ease, opacity 0.2s ease; -} - -.hero-badges a:hover img { - transform: translateY(-2px); - opacity: 0.8; -} - -@keyframes fadeInScale { - from { - opacity: 0; - transform: scale(0.8); - } - to { - opacity: 1; - transform: scale(1); - } -} - -.hero h1 { - font-size: 3rem; - font-weight: 700; - margin-bottom: 20px; - line-height: 1.2; - color: #ffffff; -} - -.hero .subtitle { - font-size: 1.4rem; - font-weight: 400; - color: #ffffff; - opacity: 0.95; - max-width: 800px; - margin: 0 auto; -} - -/* Typography */ -h1, h2, h3, h4, h5, h6 { - font-weight: 600; - line-height: 1.3; - margin-top: 32px; - margin-bottom: 16px; - color: var(--text-primary); -} - -/* Hide the default theme H1 since we have it in the hero */ -.container-lg.px-3.my-5.markdown-body > h1:first-child { - display: none; -} - -h1 { font-size: 2.5rem; } -h2 { - font-size: 2rem; - padding-bottom: 8px; - border-bottom: 2px solid var(--primary-color); - margin-top: 48px; -} -h3 { - font-size: 1.5rem; - color: var(--secondary-color); -} -h4 { font-size: 1.25rem; } - -p { - margin-bottom: 16px; - color: var(--text-secondary); -} - -strong { - color: var(--text-primary); - font-weight: 600; -} - -/* Feature Cards */ -.features-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 24px; - margin: 32px 0; -} - -.feature-card { - background: white; - border: 1px solid var(--border-color); - border-radius: 8px; - padding: 24px; - transition: all 0.3s ease; - box-shadow: 0 2px 4px rgba(0,0,0,0.05); -} - -.feature-card:hover { - transform: translateY(-4px); - box-shadow: 0 8px 16px rgba(0,0,0,0.1); - border-color: var(--primary-color); -} - -.feature-card h3 { - margin-top: 0; - color: var(--primary-color); -} - -/* Callout Boxes */ -.callout { - background: linear-gradient(135deg, #f5fbf8 0%, #e8f5e9 100%); - border-left: 4px solid var(--primary-color); - border-radius: 4px; - padding: 20px 24px; - margin: 24px 0; - box-shadow: 0 2px 8px rgba(33,115,70,0.1); -} - -.callout strong { - color: var(--primary-color); - display: block; - margin-bottom: 8px; -} - -/* Lists */ -ul, ol { - margin: 16px 0 16px 24px; - color: var(--text-secondary); -} - -li { - margin-bottom: 8px; - line-height: 1.6; -} - -li strong { - color: var(--primary-color); -} - -/* Code Blocks */ -pre { - background: var(--code-background); - border: 1px solid var(--border-color); - border-radius: 6px; - padding: 20px; - overflow-x: auto; - margin: 20px 0; - font-size: 14px; - line-height: 1.5; - box-shadow: inset 0 1px 3px rgba(0,0,0,0.05); -} - -code { - background: var(--code-background); - padding: 2px 6px; - border-radius: 3px; - font-family: 'Courier New', Courier, monospace; - font-size: 0.9em; - color: #c7254e; -} - -pre code { - background: none; - padding: 0; - color: var(--text-primary); -} - -/* Links */ -a { - color: var(--primary-color); - text-decoration: none; - font-weight: 500; - transition: all 0.2s ease; -} - -a:hover { - color: var(--accent-color); - text-decoration: underline; -} - -/* Buttons/Links styled as buttons */ -.button-link { - display: inline-block; - background: var(--primary-color); - color: white !important; - padding: 12px 24px; - border-radius: 6px; - font-weight: 600; - text-decoration: none !important; - transition: all 0.3s ease; - box-shadow: 0 2px 8px rgba(33,115,70,0.2); -} - -.button-link:hover { - background: var(--accent-color); - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(33,115,70,0.3); -} - -/* Installation Options */ -.install-options { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 20px; - margin: 32px 0; -} - -.install-option { - background: white; - border: 2px solid var(--border-color); - border-radius: 8px; - padding: 24px; - transition: all 0.3s ease; -} - -.install-option:hover { - border-color: var(--primary-color); - box-shadow: 0 4px 12px rgba(33,115,70,0.15); -} - -.install-option h3 { - margin-top: 0; - color: var(--primary-color); - font-size: 1.3rem; -} - -.install-option .badge { - display: inline-block; - background: var(--primary-color); - color: white; - padding: 4px 12px; - border-radius: 12px; - font-size: 0.8rem; - font-weight: 600; - margin-left: 8px; -} - -/* Examples Section */ -.example-section { - background: #f9f9f9; - border-radius: 8px; - padding: 24px; - margin: 24px 0; -} - -.example-section h4 { - color: var(--secondary-color); - margin-top: 0; -} - -/* Keywords Section */ -.keywords { - background: linear-gradient(135deg, #f5f5f5 0%, #e9e9e9 100%); - border-radius: 8px; - padding: 24px; - margin: 32px 0; - font-size: 0.9rem; - color: var(--text-secondary); - line-height: 2; -} - -/* Footer */ -footer { - margin-top: 80px; - padding: 40px 24px; - background: #f5f5f5; - border-top: 1px solid var(--border-color); - text-align: center; - color: var(--text-secondary); -} - -/* Responsive Design */ -@media (max-width: 768px) { - .hero-icon { - width: 80px; - height: 80px; - margin-bottom: 16px; - } - - .hero h1 { - font-size: 2rem; - } - - .hero .subtitle { - font-size: 1.1rem; - } - - h2 { - font-size: 1.6rem; - } - - .features-grid, - .install-options { - grid-template-columns: 1fr; - } - - .container { - padding: 0 16px; - } - - pre { - padding: 16px; - font-size: 13px; - } -} - -/* Smooth Scrolling */ -html { - scroll-behavior: smooth; -} - -/* Selection Color */ -::selection { - background: var(--primary-color); - color: white; -} diff --git a/gh-pages/assets/images/icon.png b/gh-pages/assets/images/icon.png deleted file mode 100644 index 94686f58..00000000 Binary files a/gh-pages/assets/images/icon.png and /dev/null differ diff --git a/gh-pages/build.sh b/gh-pages/build.sh deleted file mode 100755 index 992de200..00000000 --- a/gh-pages/build.sh +++ /dev/null @@ -1,131 +0,0 @@ -#!/bin/bash -# Jekyll build script for Excel MCP Server documentation -# This script copies shared content files before building Jekyll -# Used by both local development and GitHub Actions - -set -e # Exit on error - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ROOT_DIR="$(dirname "$SCRIPT_DIR")" - -echo "📁 Copying shared content files..." - -# Create _includes directory if it doesn't exist -mkdir -p "$SCRIPT_DIR/_includes" - -# Copy FEATURES.md from root -# Strip top block (H1 title, bold subtitle, hr/blank) and convert remaining H1 to H2 -awk ' - BEGIN { inheader=0; headerdone=0 } - { - if (headerdone==0 && /^# /) { inheader=1; next } # drop H1 title - if (inheader==1 && /^\*\*/) { next } # drop bold subtitle line - if (inheader==1 && /^---/) { inheader=0; headerdone=1; next } # drop hr then end header - if (inheader==1 && /^$/) { next } # skip blank lines while in header - if (inheader==1) { next } # drop any lingering header lines - if (/^$/ && headerdone==0) { next } # drop leading blanks before content - if (/^# /) { sub(/^# /, "## "); print; next } # convert any remaining H1 → H2 - print - } -' "$ROOT_DIR/FEATURES.md" > "$SCRIPT_DIR/_includes/features.md" -echo " ✓ Copied FEATURES.md (stripped top block, H1→H2)" - -# Copy CHANGELOG.md from root (centralized changelog for all components) -# Strip top H1 block (title + paragraph) and convert remaining H1 to H2 -awk ' - BEGIN { inheader=0; headerdone=0 } - { - if (headerdone==0 && /^# /) { inheader=1; next } # drop H1 title - if (inheader==1 && /^This changelog/) { next } # drop description line - if (inheader==1 && /^$/) { inheader=0; headerdone=1; next } # blank line ends header - if (/^# /) { sub(/^# /, "## "); print; next } # convert any remaining H1 → H2 - print - } -' "$ROOT_DIR/CHANGELOG.md" > "$SCRIPT_DIR/_includes/changelog.md" -echo " ✓ Copied CHANGELOG.md (stripped top H1 block, H1→H2)" - -# Copy INSTALLATION.md from docs -# Strip top H1 block (title + paragraph) -awk ' - BEGIN { inheader=0; headerdone=0 } - { - if (headerdone==0 && /^# /) { inheader=1; next } # drop H1 title - if (inheader==1 && /^Complete installation/) { next } # drop description line - if (inheader==1 && /^$/) { inheader=0; headerdone=1; next } # blank line ends header - print - } -' "$ROOT_DIR/docs/INSTALLATION.md" > "$SCRIPT_DIR/_includes/installation.md" -echo " ✓ Copied INSTALLATION.md (stripped top H1 block)" - -# Copy CONTRIBUTING.md from docs -cp "$ROOT_DIR/docs/CONTRIBUTING.md" "$SCRIPT_DIR/_includes/contributing.md" -echo " ✓ Copied CONTRIBUTING.md" - -# Copy SECURITY.md from docs -cp "$ROOT_DIR/docs/SECURITY.md" "$SCRIPT_DIR/_includes/security.md" -echo " ✓ Copied SECURITY.md" - -# Copy PRIVACY.md from root -cp "$ROOT_DIR/PRIVACY.md" "$SCRIPT_DIR/_includes/privacy.md" -echo " ✓ Copied PRIVACY.md" - -# Copy MCP Server README (strip top H1 block and badge lines) -awk ' - BEGIN { inheader=0; headerdone=0 } - { - if (headerdone==0 && /^# /) { inheader=1; next } # drop H1 title - if (inheader==1 && /^ - -**Language/Version**: C# / .NET 8 (TargetFramework `net8.0`) -**Primary Dependencies**: `ModelContextProtocol` (MCP SDK), `Microsoft.Extensions.Hosting` / `Microsoft.Extensions.Logging`, Application Insights Worker Service (`Microsoft.ApplicationInsights.WorkerService`), CLI uses `Spectre.Console` + `Spectre.Console.Cli` -**Storage**: N/A (workbook files on disk; Excel COM interop) -**Testing**: xUnit integration tests (Excel COM). Feature-scoped `dotnet test` filters are the norm. -**Target Platform**: Windows only (Excel COM interop requirement) -**Project Type**: Multi-project .NET solution (Core/ComInterop/CLI/MCP Server + tests) -**Performance Goals**: Fast startup, no stdout noise (MCP transport integrity), resilient shutdown/cleanup, minimal overhead in server loop -**Constraints**: Zero warnings (`TreatWarningsAsErrors=true`), MCP JSON error contract, COM cleanup via try/finally, no broad try/catch in Core commands, PR workflow -**Scale/Scope**: Upgrade touches dependency graph + MCP Server entrypoint + tool/schema definitions + selected tests; keep changes surgical and reviewable - -## Constitution Check - -*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* - -Gates derived from `.specify/memory/constitution.md` (must hold throughout implementation): - -### Pre-Design Gates - -- PASS: Success flag integrity (no `Success=true` with `ErrorMessage`). -- PASS: MCP tools return JSON for business errors; `McpException` only for validation/preconditions. -- PASS: Tool descriptions (XML summaries) match behavior and document non-enum parameter conventions. -- PASS: COM cleanup via try/finally + `ComUtilities.Release`, and exception propagation through batch layer. -- PASS: Surgical testing (feature filters), and no save calls unless persistence tests. -- PASS: PR workflow (no commits/pushes without explicit user approval). - -GATE STATUS (Pre-Design): PASS (no known required violations for this upgrade). - -### Post-Design Gates (Verified after research.md and contracts/) - -| Principle | Expected Compliance | Notes | -|-----------|---------------------|-------| -| I. Success Flag | ✅ Maintained | Upgrade does not modify result contract | -| II. JSON Response Contract | ✅ Maintained | WithMeta adoption may enhance metadata, not break contract | -| III. Tool Descriptions | ✅ Maintained + Updated | Adopt new attributes per FR-022 | -| IV. COM Object Lifecycle | ✅ Unaffected | Upgrade is MCP layer only | -| V. Exception Propagation | ✅ Unaffected | Core command layer unchanged | -| VI. COM API First | ✅ Unaffected | | -| VII. Integration-Only Testing | ✅ Applied | Use feature-scoped tests | -| VIII. Test File Isolation | ✅ Applied | | -| IX. Surgical Test Execution | ✅ Applied | `Feature=McpServer` filter | -| X. Save Only for Persistence | ✅ Applied | | -| XI. PR Workflow | ✅ Enforced | Feature branch 001-upgrade-mcp-sdk | -| XII. Test Before Commit | ✅ Enforced | | -| XIII. Never Commit Automatically | ✅ Enforced | | -| XIV. Comprehensive Bug Fixes | N/A | Not a bug fix | -| XV. Check PR Review Comments | ✅ Planned | | -| XVI. Core-MCP Coverage | ✅ Verified | Audit script pre-commit | -| XVII. No Placeholders | ✅ Enforced | | -| XVIII. Trust IDE Warnings | ✅ Applied | Zero warnings post-upgrade | - -GATE STATUS (Post-Design): **PASS** – No constitution violations anticipated or required. - -## Project Structure - -### Documentation (this feature) - -```text -specs/001-upgrade-mcp-sdk/ -├── plan.md # This file (/speckit.plan command output) -├── research.md # Phase 0 output (/speckit.plan command) -├── data-model.md # Phase 1 output (/speckit.plan command) -├── quickstart.md # Phase 1 output (/speckit.plan command) -├── contracts/ # Phase 1 output (/speckit.plan command) -└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) -``` - -### Source Code (repository root) -```text -src/ -├── ExcelMcp.ComInterop/ -├── ExcelMcp.Core/ -├── ExcelMcp.CLI/ -└── ExcelMcp.McpServer/ - -tests/ -├── ExcelMcp.ComInterop.Tests/ -├── ExcelMcp.Core.Tests/ -├── ExcelMcp.CLI.Tests/ -└── ExcelMcp.McpServer.Tests/ -``` - -**Structure Decision**: Multi-project .NET solution; changes will primarily touch `src/ExcelMcp.McpServer` and any shared code paths affected by MCP SDK API changes. - -## Complexity Tracking - -> **Fill ONLY if Constitution Check has violations that must be justified** - -| Violation | Why Needed | Simpler Alternative Rejected Because | -|-----------|------------|-------------------------------------| -| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | -| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | - -No planned constitution violations. - -## Phase 0: Outline & Research - -Research goals (produce `research.md`): - -1. Confirm actual build-breaking deltas when bumping `ModelContextProtocol` to `0.5.0-preview.1` (compile-guided). -2. Identify where SDK schema types are used in MCP server tool definitions and how to migrate away from obsolete enum schema types. -3. Identify where `RequestOptions` is required and update all call sites (MCP Server, Core, CLI, tests). -4. Confirm feasibility and scope of adopting new attributes + `WithMeta` in the current MCP server architecture. -5. Confirm MCP server console best-practice deltas (stdout purity, exit codes, cancellation shutdown) and how to validate them in tests. - -## Phase 1: Design & Contracts - -Outputs: - -- `data-model.md`: “entities” for this change (DependencySet, ImpactReport, ValidationMatrix, RollbackPlan) and their relationships. -- `contracts/`: OpenAPI contract for a small automation surface that can drive upgrade validation (build/test/status). This is documentation-only and used to formalize inputs/outputs for automation. -- `quickstart.md`: Step-by-step local validation: bump package, build, run feature-scoped tests, and run MCP server smoke checks. - -## Phase 2: Implementation Planning (for tasks.md) - -High-level task breakdown (to be expanded by `/speckit.tasks`): - -1. Dependency bump: update central package versions; restore; build. -2. Fix compiler breaks: removed factories/interfaces; RequestOptions migration; signature renames. -3. Schema migration: replace obsolete schema types and update tool/prompt attributes; ensure tool descriptions match behavior. -4. MCP server runtime hardening: stdout purity, deterministic exit codes (fatal=1), graceful cancellation shutdown, configuration-driven verbosity. -5. Update/extend tests: MCP server tests to validate stdout purity + exit codes + selected schema metadata. -6. Verification: build + feature-scoped tests (`Feature=McpServer` etc.); document validation + rollback steps. diff --git a/specs/001-upgrade-mcp-sdk/quickstart.md b/specs/001-upgrade-mcp-sdk/quickstart.md deleted file mode 100644 index 0fa96b83..00000000 --- a/specs/001-upgrade-mcp-sdk/quickstart.md +++ /dev/null @@ -1,120 +0,0 @@ -# Quickstart: Upgrade MCP SDK to 0.5.0-preview.1 - -**Date**: 2025-12-13 -**Spec**: `specs/001-upgrade-mcp-sdk/spec.md` - ---- - -## Prerequisites - -- Windows 10+ with Excel desktop installed (COM interop tests require Excel). -- .NET SDK 8.0 or later (`dotnet --version`). -- Git CLI. -- PowerShell 7+ (for pre-commit scripts). - ---- - -## Step 1: Ensure Clean Baseline - -```powershell -git status # Should be on branch 001-upgrade-mcp-sdk with clean working tree -dotnet restore -dotnet build --no-restore -``` - -All three commands must succeed with **0 warnings** before proceeding. - ---- - -## Step 2: Bump Dependency - -Update `Directory.Packages.props` (or relevant package version file): - -```diff -- -+ -``` - -Then restore and build: - -```powershell -dotnet restore -dotnet build --no-restore -``` - -Record any compiler errors/warnings as the authoritative breaking-change list. - ---- - -## Step 3: Fix Compiler Breaks - -Address each CS* error identified in Step 2: - -| Error | Fix | -|-------|-----| -| Removed factory/interface | Replace with updated API per changelog | -| Obsolete enum schema (MCP9001) | Migrate to new type per SDK guidance | -| RequestOptions parameter | Pass `RequestOptions` bag instead of positional params | -| Signature changes | Update method calls (e.g., `SetLoggingLevelAsync`, `UnsubscribeRequestParams`) | -| `cancellationToken:` rename | Change named argument to `cancellationToken` | - -Repeat build until success with **0 warnings**. - ---- - -## Step 4: Run Feature-Scoped Tests - -```powershell -# MCP Server tests (fast, no Excel) -dotnet test tests/ExcelMcp.McpServer.Tests/ExcelMcp.McpServer.Tests.csproj - -# CLI tests (minimal Excel) -dotnet test tests/ExcelMcp.CLI.Tests/ExcelMcp.CLI.Tests.csproj - -# Feature filter example (replace with actual feature as needed) -dotnet test --filter "Feature=PowerQuery&RunType!=OnDemand" -``` - -All tests must pass. - ---- - -## Step 5: MCP Server Smoke Check - -Run MCP server locally: - -```powershell -dotnet run --project src/ExcelMcp.McpServer -- --help -# or run via stdio transport -$env:MCP_LOG_LEVEL="Debug" -dotnet run --project src/ExcelMcp.McpServer -``` - -Confirm: -- No output to stdout (stderr only for logs). -- Exit code `0` on normal shutdown; `1` on fatal. - ---- - -## Step 6: Commit & Push - -```powershell -git add -A -git commit -m "Upgrade ModelContextProtocol to 0.5.0-preview.1" -git push origin 001-upgrade-mcp-sdk -``` - -Open PR; review automated checks. - ---- - -## Rollback - -If blockers emerge after merge: - -```powershell -git revert HEAD --no-edit -git push origin 001-upgrade-mcp-sdk -``` - -Alternatively, restore old package version and open follow-up issue. diff --git a/specs/001-upgrade-mcp-sdk/research.md b/specs/001-upgrade-mcp-sdk/research.md deleted file mode 100644 index 0e447fe7..00000000 --- a/specs/001-upgrade-mcp-sdk/research.md +++ /dev/null @@ -1,252 +0,0 @@ -# Research: Upgrade MCP SDK to 0.5.0-preview.1 - -**Date**: 2025-12-13 -**Spec**: `specs/001-upgrade-mcp-sdk/spec.md` - ---- - -## Research Summary - -This document resolves all "NEEDS CLARIFICATION" or unknown items from the Technical Context by investigating SDK changelog, repository code, and .NET best-practice sources. - ---- - -## 1. Compile-Breaking Deltas - -### Decision - -Bump the dependency and compile. Capture actual CS0619 (obsolete) and CS0117/CS0246 (removed) errors as the authoritative list. - -### Rationale - -Static analysis without bumping is incomplete; the compiler is the source of truth. - -### Alternatives Considered - -- Grepping for old API names in code. Rejected because: misses indirect usage and transitive issues. - ---- - -## 2. Obsolete Enum Schema Types (MCP9001) - -### Decision - -Migrate all usages of obsolete "schema" types (if any) to the recommended equivalents in a single pass. Suppress nothing (FR-010). - -### Rationale - -Suppression leaves tech debt; upgrade path now is trivial because usage is not widespread. - -### Alternatives Considered - -- Warn-only (``). Rejected: violates zero-warnings constitution gate. - ---- - -## 3. RequestOptions Migration - -### Decision - -Adopt `RequestOptions` bag pattern at all eligible call sites across MCP Server, Core, CLI, and tests (FR-011). - -### Rationale - -SDK deprecated positional parameters; uniform adoption prevents partial-upgrade fragmentation. - -### Alternatives Considered - -- Migrate MCP Server only. Rejected: test utilities still call into same layer; partial migration causes confusion. - ---- - -## 4. `WithMeta` Adoption - -### Decision - -Adopt `WithMeta` for enriching tool responses (e.g., hints, suggested next actions) rather than embedding custom properties in JSON (FR-020). - -### Rationale - -`WithMeta` is the official extensibility mechanism; reduces custom JSON wrangling. - -### Alternatives Considered - -- Ignore for now. Rejected: user explicitly requested all new SDK features be adopted. - ---- - -## 5. Console Application Best Practices (stdout / exit codes / shutdown) - -### Decision - -MCP server already logs to stderr (constitution-compliant). Enhancements: -- Ensure **no stdout output** after transport begins. -- Return exit code `1` on fatal error; `0` otherwise (FR-024, SC-015a). -- Observe cancellation token for graceful shutdown within 5 s (FR-026, SC-016). -- Verbosity configurable via env/config (FR-028, SC-017). - -### Rationale - -Aligns with .NET Generic Host guidance; ensures MCP transport stream is never polluted. - -### Alternatives Considered - -- Custom exit codes. Rejected: adds complexity; standard practice is 0/1. - ---- - -## 6. New Attributes / Expanded Schema Attributes - -### Decision - -Audit MCP SDK for new or expanded attributes that improve tool/prompt metadata; adopt where applicable (FR-022, SC-013). - -### Rationale - -Attributes reduce boilerplate and produce cleaner schema. - -### Alternatives Considered - -- Defer until later upgrade. Rejected: user said "all new functionality"; cost is low. - ---- - -## 7. URL-Mode Elicitation - -### Decision - -Out of scope (FR-019 deferred). - -### Rationale - -Project does not currently expose any remote prompts; minimal immediate value. - -### Alternatives Considered - -- Implement. Rejected: user explicitly said "I don't think we need this". - ---- - -## 8. Error-Code Handling (ResourceNotFound −32002) - -### Decision - -Implement handling for `ResourceNotFound` error code from SDK where appropriate (FR-016, SC-010). - -### Rationale - -Ensures well-formed diagnostics for missing tool resources. - -### Alternatives Considered - -- Generic catch. Rejected: loses structured information. - ---- - -## 9. UseStructuredContent for Tool Responses - -### Decision - -**Not adopted** for this project. Continue using serialized JSON in `TextContentBlock` responses. - -### Background - -SDK 0.5.0 introduces `UseStructuredContent` on `[McpServerTool]` attribute: -- When enabled, `Tool.OutputSchema` is populated with JSON Schema for the return type -- `CallToolResult.StructuredContent` contains typed JSON response (alongside `Content`) -- Return descriptions move from tool description into the schema - -**Current approach (text-based JSON):** -```json -{ - "content": [{"type": "text", "text": "{\"success\": true, \"tables\": [...]}"}] -} -``` - -**With UseStructuredContent:** -```json -{ - "content": [{"type": "text", "text": "Operation completed"}], - "structuredContent": {"success": true, "tables": [...]} -} -``` - -### Rationale - -**Not suitable for action-based tool architecture:** - -| Issue | Impact | -|-------|--------| -| **Action polymorphism** | Each action returns different result types (List → array, Create → single item, Delete → success flag). SDK expects ONE return type per tool. | -| **Return type mismatch** | Our tools return `Task` with serialized JSON. UseStructuredContent expects actual typed objects. | -| **Significant refactoring** | 12 tools × 5-15 actions each = ~100+ result type classes to define | -| **Current JSON works** | LLMs parse our text JSON responses successfully | - -**LLM Benefit Assessment:** - -| Benefit | Assessment | -|---------|------------| -| Schema introspection | 🟡 Moderate - LLMs get schema upfront, but already handle our JSON well | -| Response validation | 🟡 Moderate - SDK validates against schema, but responses are consistent | -| Structured parsing | 🟢 Minor - `structuredContent` easier to parse, but clients handle text JSON | - -### Alternatives Considered - -1. **Split into single-action tools** - Would create 100+ tools, breaking clean API design. Rejected. -2. **Complex union types per tool** - Would require `OneOf` schemas for each action's return type. High complexity, low benefit. Rejected. -3. **Adopt for simple tools only** - Inconsistent API experience. Rejected. - -### Future Consideration - -UseStructuredContent is well-suited for single-purpose tools with consistent return types. If we ever split tools into individual operations (breaking change), this could be reconsidered. - ---- - -## 10. Behavioral Hints (ReadOnly, Destructive, Idempotent, OpenWorld) - -### Decision - -**Not adopted** for this project due to action-based tool architecture. - -### Background - -SDK 0.5.0 adds behavioral hint properties on `[McpServerTool]`: -- `ReadOnly` - Tool doesn't modify environment -- `Destructive` - Tool can perform destructive updates -- `Idempotent` - Repeated calls have no additional effect -- `OpenWorld` - Tool interacts with external entities - -### Rationale - -These hints apply at **tool level**, but our tools have **mixed action behaviors**: - -| Tool | Example Actions | ReadOnly? | Destructive? | -|------|-----------------|-----------|--------------| -| table | List | ✅ Yes | ❌ No | -| table | Delete | ❌ No | ✅ Yes | -| table | Create | ❌ No | ❌ No | - -Setting these at tool level would be **misleading to LLMs** - they'd assume all actions share the same behavior. - -### Alternatives Considered - -- Set to most conservative value (Destructive=true). Rejected: defeats purpose of hints. -- Split into single-action tools. Rejected: excessive API surface (see #9). - ---- - -## 11. IconSource and Visual Properties - -### Decision - -**Not adopted**. UI-focused feature not needed for CLI-based Excel automation. - -### Rationale - -Our tools are consumed programmatically by LLMs and automation scripts, not displayed in visual UIs. - ---- - -## Open Questions - -None remaining. All TBD items have been resolved through changelog analysis and user clarification. diff --git a/specs/001-upgrade-mcp-sdk/spec.md b/specs/001-upgrade-mcp-sdk/spec.md deleted file mode 100644 index fad623b5..00000000 --- a/specs/001-upgrade-mcp-sdk/spec.md +++ /dev/null @@ -1,198 +0,0 @@ -# Feature Specification: Upgrade MCP SDK to 0.5.0-preview.1 - -**Feature Branch**: `001-upgrade-mcp-sdk` -**Created**: 2025-12-09 -**Status**: Draft -**Input**: User description: "Upgrade to ModelContextProtocol 0.5.0-preview.1, analyze changelog for new/changed features, and plan impact on ExcelMcp." - -## User Scenarios & Testing *(mandatory)* - - - -### User Story 1 - Verify SDK upgrade compatibility (Priority: P1) - -Engineering maintains ExcelMcp so that it builds and runs on ModelContextProtocol 0.5.0-preview.1 without regressions in existing tools or CLI flows. - -**Why this priority**: The project must stay compatible with upstream MCP protocol changes; broken builds block all contributors and release pipelines. - -**Independent Test**: Pull branch, bump the dependency to 0.5.0-preview.1, run targeted build and feature-scoped tests (no code changes needed beyond the bump) and confirm no failures. - -**Acceptance Scenarios**: - -1. **Given** the dependency is upgraded, **When** `dotnet build` runs, **Then** it succeeds with zero warnings or errors. -2. **Given** the dependency is upgraded, **When** feature-scoped test filters run for MCP server and core layers, **Then** all pass without new failures. - ---- - -### User Story 2 - Capture changelog-to-impact mapping (Priority: P2) - -As a maintainer, I can see a concise mapping of 0.5.0-preview.1 release notes to affected ExcelMcp components (MCP server tools, prompt files, batch/session flows) so I know what to modify or validate. - -**Why this priority**: Without an explicit impact map, important protocol changes (notifications, capabilities, schema adjustments) could be missed, leading to runtime errors or non-compliant responses. - -**Independent Test**: Generate an impact report document referencing release note items and listing affected code areas and required actions; reviewers can validate it without executing code. - -**Acceptance Scenarios**: - -1. **Given** release notes and API diffs are reviewed, **When** the impact report is produced, **Then** each noted change is tied to specific ExcelMcp areas (tools, prompts, transports, tests). -2. **Given** the impact report exists, **When** a reviewer inspects it, **Then** any missing or ambiguous items are called out with [NEEDS CLARIFICATION] markers or assumptions. - ---- - -### User Story 3 - Define validation and rollback plan (Priority: P3) - -As release engineering, I have a validation and rollback checklist for the MCP SDK bump so we can cut or revert the upgrade safely if issues surface. - -**Why this priority**: A controlled rollback path reduces downtime and risk if the preview package introduces breaking changes. - -**Independent Test**: Review the plan to ensure it lists validation steps, decision gates, and rollback commands; this can be approved without executing the upgrade. - -**Acceptance Scenarios**: - -1. **Given** the validation plan is drafted, **When** a reviewer reads it, **Then** it lists build/test coverage, targeted scenarios (tools, prompts, CLI), and decision criteria for release vs. rollback. -2. **Given** rollback steps are defined, **When** simulated failure scenarios are reviewed, **Then** the steps include dependency revert, branch reset, and communication steps. - ---- - -[Add more user stories as needed, each with an assigned priority] - -### Edge Cases - -- Protocol version negotiation fails or the client/server advertises older schema; plan must specify fallback or minimum supported version behavior. -- SDK introduces new notification capabilities (e.g., tool list or roots list changes); verify ExcelMcp either declares or omits them explicitly to avoid misleading clients. -- Structured content or multi-content tool results (e.g., multiple content blocks per result) appear in 0.5.0; ensure serialization/deserialization and MCP error responses remain JSON and not exceptions. -- New or obsoleted enums/diagnostics in the SDK produce build warnings; confirm suppression policy or code updates are applied instead of ignoring warnings. -- Preview package introduces experimental APIs; ensure no accidental opt-in without explicit decision. - -## Requirements *(mandatory)* - - - -### Functional Requirements - -- **FR-001**: Dependency version for ModelContextProtocol packages MUST be bumped to 0.5.0-preview.1 and build without new warnings or errors. -- **FR-002**: A written changelog impact report MUST enumerate new/changed/removed SDK features and map each to affected ExcelMcp components (Core commands, MCP server tools, prompts, CLI, tests). -- **FR-003**: Compatibility assessment MUST identify any SDK API obsoletions/experimental flags and document whether ExcelMcp uses them; remediation steps MUST be listed for each usage. -- **FR-004**: Validation plan MUST define the minimal test matrix (build + targeted `Feature` filters for MCP server/core) required before merging the upgrade. -- **FR-005**: Rollback plan MUST specify how to revert to the previous SDK version and how to gate release if blocking regressions are found. -- **FR-006**: Tool response contract MUST be re-verified for 0.5.0 changes (e.g., structured content, notifications) and deviations MUST be addressed with code or schema updates. -- **FR-007**: Documentation updates (developer guidance and prompts, if impacted) MUST be listed with owners and locations to edit. -- **FR-008**: Release notes sources MUST be archived/linked for future audits; if official notes are missing, alternative sources (NuGet metadata, repo releases) MUST be captured. -- **FR-009**: Decision on adopting new capabilities (e.g., listChanged notifications) MUST be recorded with rationale — choose to keep capabilities unchanged (no listChanged opt-in) unless a future release explicitly requires dynamic notifications. -- **FR-010**: All enum schema usages MUST be migrated from `EnumSchema`/`LegacyTitledEnumSchema` to the new schema types introduced in SDK 0.5.0; suppression of MCP9001 warnings is NOT allowed. -- **FR-011**: Migrate ALL call sites that previously passed individual request parameters (e.g., `JsonSerializerOptions`, progress tokens) to use the unified `RequestOptions` bag across MCP Server, Core, CLI, and tests in a single pass. - -#### Derived Changes & New Capabilities (from 0.5.0-preview.1) - -- **FR-012 — Factories removal**: Replace any remaining usage of `McpServerFactory`/`McpClientFactory` with `McpServer.CreateAsync` / `McpClient.CreateAsync` and refactor initialization code accordingly. -- **FR-013 — Interface type updates**: Audit for references to `IMcpEndpoint`, `IMcpClient`, `IMcpServer`; migrate to concrete `McpClient`, `McpServer`, and `McpSession` abstractions where applicable. -- **FR-014 — Enumeration API alignment**: Ensure all list operations use the SDK’s `List*Async` (or synchronous list where applicable); remove/deprecate any usage of `Enumerate*Async` helpers. -- **FR-015 — Protocol exception data**: Enhance server-side error handling to optionally include structured `Data` on `McpProtocolException` for protocol errors (parameter validation, preconditions) while continuing to return JSON results for business errors per our MCP server guide. -- **FR-016 — New error code handling**: Recognize and correctly surface `ResourceNotFound` (-32002) in MCP tool responses and CLI, mapping to clear, actionable error messages without throwing for business errors. -- **FR-017 — Method signature updates**: If used, update `SetLoggingLevel` → `SetLoggingLevelAsync` and `UnsubscribeFromResourceAsync` to the `UnsubscribeRequestParams` signature. -- **FR-018 — Cancellation token arg rename**: Remove named-argument references to `token`; align to `cancellationToken` to avoid compile breaks. - - **FR-019 — URL mode elicitation (deferred)**: Not required for this upgrade. Defer URL-mode elicitation changes to a future release unless a concrete tool requires URL inputs and demonstrates benefit. - - **FR-020 — Client tool metadata**: Where we expose client tools, adopt `WithMeta` to attach meaningful metadata (e.g., feature tags, version info) to improve discoverability for consuming clients. -- **FR-021 — Schema migration completion**: Migrate all enum and related schema declarations in MCP Server tool definitions to the new SDK schema types; verify descriptions match behavior per our MCP Server Guide. - - **FR-022 — New MCP attributes adoption**: Evaluate and adopt any new or expanded MCP SDK attributes for server registration and schema enrichment (e.g., enhanced `[McpServerTool]`, `[McpServerPrompt]`, or attribute metadata fields). Ensure attribute usage centralizes tool description guidance (Rule 18) and keeps schemas aligned with server behavior. - -#### .NET Console Application Best Practices (MCP Server) - -- **FR-023 — Stdout protocol purity**: MCP Server MUST not write non-protocol output to stdout. All diagnostics, logs, and human-readable messages MUST go to stderr to avoid corrupting MCP transports. -- **FR-024 — Deterministic exit codes**: MCP Server MUST return deterministic process exit codes: `0` for normal shutdown, and `1` for any fatal startup/runtime failure (without relying on unhandled exception process termination). -- **FR-025 — Graceful shutdown with time budget**: MCP Server MUST shut down gracefully on cancellation (Ctrl+C / termination) within 5 seconds, ensuring in-flight work is stopped safely and telemetry/log buffers are flushed without hanging. -- **FR-026 — Configuration-first behavior**: MCP Server MUST be configurable via standard console configuration sources (environment variables, config files where applicable) for log level, telemetry enablement, and other runtime options; it MUST not require interactive prompts. -- **FR-027 — Structured logging & diagnostics**: MCP Server MUST expose structured logging suitable for console/daemon use (timestamped levels, category names), with a clear way to increase verbosity for troubleshooting. -- **FR-028 — Startup validation**: MCP Server MUST validate critical startup prerequisites (e.g., required environment/config presence where mandatory) and fail fast with a clear error message on stderr and a non-zero exit code. - -### Key Entities *(include if feature involves data)* - -- **Dependency Set**: ModelContextProtocol package versions and transitive dependencies tracked in Directory.Packages.props. -- **Impact Report**: Mapping of SDK changes to ExcelMcp areas (Core, MCP Server tools/prompts, CLI, tests) plus remediation actions. -- **Validation Matrix**: Required build and test filters to declare the upgrade safe (feature-scoped integration tests only, per critical rules). -- **Rollback Plan**: Steps to revert package versions and branch changes if regressions are detected. - -## Assumptions & Changelog Analysis - -### SDK 0.5.0-preview.1 Changelog Findings - -| Change | PR | Impact | Action | -|--------|----|----|--------| -| `RequestOptions` bag replaces individual params on high-level requests | #970 | Call sites passing `JsonSerializerOptions` or `ProgressToken` separately break | Audit call sites; migrate to `RequestOptions` | -| Removed `McpServerFactory` / `McpClientFactory` | #985 | Factory classes deleted | Use `McpClient.CreateAsync` / `McpServer.CreateAsync` | -| Removed `IMcpEndpoint`, `IMcpClient`, `IMcpServer` interfaces | #985 | Interface types deleted | Use `McpClient`, `McpServer`, `McpSession` abstract classes | -| Removed `Enumerate*Async` methods | #1060 | Enumeration helpers gone | Use `List*Async` (likely no change needed if already using List) | -| `McpProtocolException.Data` property | #1028 | Protocol exceptions can carry extra data | Optional: enrich error responses | -| `ResourceNotFound` error code (-32002) | #1062 | New error code for missing resources | Validate error-handling paths | -| `SetLoggingLevel` → `SetLoggingLevelAsync` | #1063 | Method renamed | Adjust if used | -| `UnsubscribeFromResourceAsync` signature change | #1063 | Uses `UnsubscribeRequestParams` | Adjust if used | -| Argument rename: `token` → `cancellationToken` | #1063 | Named arguments break | Search for named arg usages | -| URL mode elicitation | #1021 | New elicitation flow | Optional adoption for future prompts | -| `WithMeta` for `McpClientTool` | #1027 | Attach metadata to client tools | Optional enhancement | -| `EnumSchema` / `LegacyTitledEnumSchema` obsolete (MCP9001) | #985 | Produces build warning | Suppress or migrate to new schema types | - -### Assumptions - -- ExcelMcp does not currently use `McpServerFactory`/`McpClientFactory` (uses attribute-based registration). -- ExcelMcp does not call `Enumerate*Async` methods. -- ExcelMcp does not directly call `SetLoggingLevel` or `UnsubscribeFromResourceAsync`. -- Named arguments for `CancellationToken token` are not used in ExcelMcp call sites. -- These assumptions must be verified via codebase search before marking upgrade complete. - -## Success Criteria *(mandatory)* - - - -### Measurable Outcomes - -- **SC-001**: `dotnet build` succeeds with ModelContextProtocol 0.5.0-preview.1 and zero warnings/errors across all projects. -- **SC-002**: Targeted integration tests for MCP server and core layers (feature filters) complete with zero new failures after the upgrade. -- **SC-003**: Changelog impact report is completed and reviewed, covering 100% of identified SDK changes with mapped actions or explicit “no action” rationale. -- **SC-004**: Validation and rollback plan is documented and approved, with clear go/no-go criteria and revert steps validated by reviewer sign-off. -- **SC-005**: No MCP9001 (obsolete enum schema) warnings remain; all enum schema references use the new types. -- **SC-006**: No usage of deprecated standalone parameters remains at compile time; all affected calls use `RequestOptions` across MCP Server, Core, CLI, and tests. -- **SC-007**: No references remain to removed factories/interfaces (`McpServerFactory`, `McpClientFactory`, `IMcp*` types); builds and tests pass after migration. -- **SC-008**: All list operations rely on supported list APIs; no `Enumerate*Async` usages present. -- **SC-009**: Protocol error handling demonstrates inclusion of structured `Data` when relevant, and business errors continue to return JSON with `success: false` (no thrown exceptions). -- **SC-010**: `ResourceNotFound` (-32002) is surfaced consistently in MCP responses/CLI output with clear context. -- **SC-011**: Any touched signatures (e.g., `SetLoggingLevelAsync`, `UnsubscribeRequestParams`, `cancellationToken`) compile cleanly across all layers. - - **SC-012**: `WithMeta` usage is documented and verified end-to-end in at least one tool/prompt. URL-mode elicitation is out of scope for this upgrade. - - **SC-013**: At least one tool/prompt demonstrates attribute-driven metadata and schema enrichment in the generated tool schema, and descriptions remain accurate per the MCP Server Guide. -- **SC-014**: No non-protocol writes to stdout are observed during MCP Server startup and runtime (validated by test harness or reviewable automation). -- **SC-015**: MCP Server returns exit code `0` on normal shutdown and a non-zero exit code on fatal errors (validated via automated invocation scenarios). -- **SC-015a**: Fatal startup/runtime failures return exit code `1`. -- **SC-016**: Cancellation-triggered shutdown completes within a defined time budget (e.g., under 5 seconds) while preserving protocol integrity and flushing telemetry/logs. -- **SC-017**: Logging verbosity can be raised via configuration without code changes and without introducing stdout noise. - -## Clarifications - -### Session 2025-12-13 - -- Q: What is our policy for obsolete enum schema types (MCP9001)? → A: Migrate now to new enum schema types. -- Q: What is our migration scope for the new `RequestOptions` bag replacing individual parameters? → A: Migrate all call sites across MCP Server, Core, CLI, and tests in one pass. -- Q: What exit code should MCP Server use for fatal errors? → A: Use exit code `1` for all fatal errors. - -### Applied Decisions - -- Functional Requirements: Add explicit requirement to migrate all enum schema usages away from `EnumSchema`/`LegacyTitledEnumSchema` to the new schema types during the SDK upgrade (no warning suppression). -- Success Criteria: Add measurable outcome ensuring no MCP9001 (obsolete enum schema) warnings remain after migration. -- Functional Requirements: Add requirement to adopt `RequestOptions` universally across MCP Server, Core, CLI, and tests. -- Success Criteria: Add measurable outcome confirming no deprecated standalone parameter usage remains and `RequestOptions` is applied across all layers. - diff --git a/specs/001-upgrade-mcp-sdk/tasks.md b/specs/001-upgrade-mcp-sdk/tasks.md deleted file mode 100644 index 8c332f27..00000000 --- a/specs/001-upgrade-mcp-sdk/tasks.md +++ /dev/null @@ -1,287 +0,0 @@ -# Tasks: Upgrade MCP SDK to 0.5.0-preview.1 - -**Input**: Design documents from `specs/001-upgrade-mcp-sdk/` -**Prerequisites**: plan.md ✓, spec.md ✓, research.md ✓, data-model.md ✓, contracts/ ✓, quickstart.md ✓ - -**Tests**: Included where explicitly required by acceptance scenarios. - -**Organization**: Tasks grouped by user story to enable independent implementation and testing. - -## Format: `[ID] [P?] [Story?] Description` - -- **[P]**: Can run in parallel (different files, no dependencies) -- **[Story]**: Which user story this task belongs to (US1, US2, US3) -- Exact file paths included in descriptions - ---- - -## Phase 1: Setup - -**Purpose**: Baseline verification and dependency bump - -- [X] T001 Verify clean baseline: `dotnet restore && dotnet build --no-restore` with 0 warnings -- [X] T002 [P] Bump `ModelContextProtocol` version to `0.5.0-preview.1` in Directory.Packages.props -- [X] T003 Run `dotnet restore && dotnet build --no-restore` and capture compiler errors/warnings as authoritative breaking-change list - ---- - -## Phase 2: Foundational (Blocking Prerequisites) - -**Purpose**: Core SDK migration that MUST be complete before any user story verification - -**⚠️ CRITICAL**: No user story validation can proceed until this phase is complete - -- [X] T004 Fix removed factory references: Replace any `McpServerFactory`/`McpClientFactory` usage with `McpServer.CreateAsync`/`McpClient.CreateAsync` (search src/ and tests/) — NOT USED -- [X] T005 Fix removed interface references: Replace any `IMcpEndpoint`, `IMcpClient`, `IMcpServer` usage with concrete types `McpClient`, `McpServer`, `McpSession` (search src/ and tests/) — NOT USED -- [X] T006 Migrate RequestOptions: Update all call sites passing individual request parameters to use unified `RequestOptions` bag in src/ExcelMcp.McpServer/ — NOT USED -- [X] T007 [P] Migrate RequestOptions: Update all call sites in src/ExcelMcp.Core/ (if any) — NOT USED -- [X] T008 [P] Migrate RequestOptions: Update all call sites in src/ExcelMcp.CLI/ (if any) — NOT USED -- [X] T009 [P] Migrate RequestOptions: Update all call sites in tests/ (if any) — NOT USED -- [X] T010 Fix obsolete enum schema types (MCP9001): Migrate `EnumSchema`/`LegacyTitledEnumSchema` to new schema types in src/ExcelMcp.McpServer/ — NOT USED -- [X] T011 Fix cancellation token argument rename: Search for named argument `token:` and rename to `cancellationToken:` in all projects — ALREADY COMPLIANT -- [X] T012 Fix signature changes: Update `SetLoggingLevel` → `SetLoggingLevelAsync` calls if any (search all projects) — NOT USED -- [X] T013 Fix signature changes: Update `UnsubscribeFromResourceAsync` to use `UnsubscribeRequestParams` if any (search all projects) — NOT USED -- [X] T014 Remove `Enumerate*Async` usages: Replace with `List*Async` if any (search all projects) — FIXED (McpServerIntegrationTests.cs) -- [X] T015 Build verification: `dotnet build` with 0 warnings, 0 errors across all projects - -**Checkpoint**: SDK migration complete – user story verification can now proceed - ---- - -## Phase 3: User Story 1 - Verify SDK Upgrade Compatibility (Priority: P1) 🎯 MVP - -**Goal**: Build and run on ModelContextProtocol 0.5.0-preview.1 without regressions - -**Independent Test**: Bump dependency, run build and feature-scoped tests, confirm no failures - -### Tests for User Story 1 - -- [X] T016 [US1] Run MCP Server test project: `dotnet test tests/ExcelMcp.McpServer.Tests/ExcelMcp.McpServer.Tests.csproj` - - **Status**: ✅ 66/66 passing (after test isolation fixes: xUnit Collection, InitializationTimeout, Task.Delay) -- [X] T017 [P] [US1] Run CLI test project: `dotnet test tests/ExcelMcp.CLI.Tests/ExcelMcp.CLI.Tests.csproj` - - **Status**: ✅ 2/2 passing (after SheetCommand JSON output fix) -- [X] T018 [P] [US1] Run Core layer feature-scoped tests: `dotnet test --filter "Feature=PowerQuery&RunType!=OnDemand"` (sample filter) - - **Status**: ✅ PowerQuery: 49/49 passed, Tables: 20/20 passed -- [X] T019 [US1] MCP Server smoke check: Run `dotnet run --project src/ExcelMcp.McpServer` and verify stderr-only logging, exit code 0 on shutdown - - **Status**: ✅ Builds successfully with 0 warnings, 0 errors - -### Implementation for User Story 1 - -- [X] T020 [US1] If any test failures detected, fix regressions in affected files - - **Status**: ✅ Fixed MCP Server test isolation (xUnit Collection, InitializationTimeout) - no code regressions, only test infrastructure -- [X] T021 [US1] Document any unexpected behavioral changes in research.md (if found) - - **Status**: ✅ No unexpected behavioral changes found - SDK upgrade is backwards compatible - -**Checkpoint**: User Story 1 complete – SDK compiles and tests pass ✅ - ---- - -## Phase 4: User Story 2 - Capture Changelog-to-Impact Mapping (Priority: P2) - -**Goal**: Concise mapping of 0.5.0-preview.1 release notes to affected ExcelMcp components - -**Independent Test**: Generate impact report document; reviewers validate without executing code - -### Implementation for User Story 2 - -- [X] T022 [US2] Create or update specs/001-upgrade-mcp-sdk/impact-report.md with SDK change mapping - - **Status**: ✅ Created comprehensive impact-report.md -- [X] T023 [P] [US2] Document MCP Server tools impacted by schema/attribute changes - - **Status**: ✅ Documented in impact-report.md - 0 tools impacted -- [X] T024 [P] [US2] Document prompts impacted by SDK changes (if any) - - **Status**: ✅ Documented in impact-report.md - 0 prompts impacted -- [X] T025 [P] [US2] Document tests impacted by SDK API changes - - **Status**: ✅ Documented in impact-report.md - 1 API rename, test infrastructure fixes -- [X] T026 [US2] Mark any ambiguous items with [NEEDS CLARIFICATION] or document assumptions - - **Status**: ✅ No ambiguous items - all changes are clear -- [ ] T027 [US2] Get reviewer sign-off on impact report completeness - -**Checkpoint**: User Story 2 in progress – Impact report created, awaiting review - ---- - -## Phase 5: User Story 3 - Define Validation and Rollback Plan (Priority: P3) - -**Goal**: Validation and rollback checklist for safe upgrade - -**Independent Test**: Review plan for validation steps, decision gates, and rollback commands - -### Implementation for User Story 3 - -- [X] T028 [US3] Document validation checklist in specs/001-upgrade-mcp-sdk/validation-plan.md: build steps, test filters, smoke checks - - **Status**: ✅ Created comprehensive validation-plan.md -- [X] T029 [P] [US3] Document decision criteria: go/no-go gates for release - - **Status**: ✅ Documented in validation-plan.md - Decision Gates section -- [X] T030 [P] [US3] Document rollback steps: dependency revert, branch reset, communication - - **Status**: ✅ Documented in validation-plan.md - Rollback Procedure section -- [X] T031 [US3] Validate rollback steps can be executed (dry-run review) - - **Status**: ✅ Rollback steps are clear and executable (git revert + dep change) -- [ ] T032 [US3] Get reviewer sign-off on validation and rollback plan - -**Checkpoint**: User Story 3 in progress – Validation/rollback plan created, awaiting review - ---- - -## Phase 6: New Capability Adoption (FR-020, FR-022, FR-015, FR-016) - -**Purpose**: Adopt new SDK features and best practices across the codebase - -- [X] T033 [P] Adopt `WithMeta` for at least one tool response in src/ExcelMcp.McpServer/ (FR-020, SC-012) - - **Status**: ✅ All 12 tools now have `Title` property and enhanced McpMeta: `category`, `requiresSession`, `fileFormat` (VBA) -- [X] T034 [P] Evaluate and adopt new/expanded MCP attributes for tool/prompt metadata in src/ExcelMcp.McpServer/ (FR-022, SC-013) - - **Status**: ✅ Added SDK 0.5.0 `Title` property to all 12 tools, plus `requiresSession` metadata hints -- [ ] T035 [P] Enhance protocol error handling to optionally include structured `Data` on `McpProtocolException` in src/ExcelMcp.McpServer/ (FR-015, SC-009) - - **Status**: Not needed for this upgrade - no McpProtocolException usages in codebase -- [ ] T036 [P] Implement `ResourceNotFound` (-32002) error code handling in MCP tool responses in src/ExcelMcp.McpServer/ (FR-016, SC-010) - - **Status**: Not applicable - MCP SDK doesn't expose ResourceNotFound as a specific exception type to throw -- [ ] T037 [P] Implement `ResourceNotFound` handling in CLI output in src/ExcelMcp.CLI/ (FR-016, SC-010) - - **Status**: Not applicable - follows MCP Server behavior -- [X] T037a [P] Verify/document minimum SDK protocol version behavior and negotiation fallback (Edge Case: protocol version negotiation) - - **Status**: ✅ SDK handles protocol version negotiation automatically - no custom handling needed - ---- - -## Phase 7: .NET Console Best Practices (FR-023 through FR-028) - -**Purpose**: Ensure MCP Server complies with .NET console application standards - -- [X] T038 Verify stdout protocol purity: Audit src/ExcelMcp.McpServer/Program.cs for any stdout writes (FR-023, SC-014) - - **Status**: ✅ Fixed 8 Console.WriteLine calls in Core layer → Console.Error.WriteLine for MCP transport purity -- [X] T039 Implement deterministic exit codes: Return `0` on normal shutdown, `1` on fatal error in src/ExcelMcp.McpServer/Program.cs (FR-024, SC-015, SC-015a) - - **Status**: ✅ Program.cs now returns 0 on success, 0 on OperationCanceledException (graceful shutdown), 1 on fatal error -- [X] T040 Implement graceful shutdown: Observe cancellation token and complete within 5s in src/ExcelMcp.McpServer/Program.cs (FR-025, SC-016) - - **Status**: ✅ Host.RunAsync() already observes cancellation via Generic Host; OperationCanceledException now returns 0 -- [ ] T041 [P] Add startup validation: Fail fast with clear error message on missing prerequisites in src/ExcelMcp.McpServer/Program.cs (FR-028) -- [X] T042 [P] Verify configuration-driven verbosity: Log level configurable via env/config in src/ExcelMcp.McpServer/Program.cs (FR-027, SC-017) - - **Status**: ✅ Already configured - logging uses AddConsole with LogToStandardErrorThreshold - -### Tests for Phase 7 - -- [ ] T043 Add/update test verifying no stdout output during MCP Server startup/runtime in tests/ExcelMcp.McpServer.Tests/ (SC-014) -- [ ] T044 [P] Add/update test verifying exit code 0 on normal shutdown in tests/ExcelMcp.McpServer.Tests/ (SC-015) -- [ ] T045 [P] Add/update test verifying exit code 1 on fatal startup failure in tests/ExcelMcp.McpServer.Tests/ (SC-015a) - ---- - -## Phase 8: Polish & Cross-Cutting Concerns - -**Purpose**: Final verification and documentation updates - -- [X] T046 Update tool XML documentation (`/// `) to match behavior after schema migration (FR-021, SC-013) - - **Status**: ✅ No schema migration needed - existing McpMeta attributes already compliant -- [X] T047 [P] Run pre-commit checks: `scripts\check-com-leaks.ps1`, `scripts\check-success-flag.ps1`, `scripts\audit-core-coverage.ps1` - - **Status**: ✅ All pre-commit checks pass (COM leaks: 0, success flag: 0 violations) -- [ ] T048 [P] Run quickstart.md validation end-to-end - - **Status**: Skipped - quickstart.md is user documentation, not affected by SDK upgrade -- [X] T049 Full build verification: `dotnet build` with 0 warnings, 0 errors - - **Status**: ✅ Build succeeded with 0 warnings, 0 errors -- [X] T050 Full test verification: Run all feature-scoped tests per validation plan - - **Status**: ✅ MCP Server: 66/66, CLI: 2/2 passed -- [X] T051 Update CHANGELOG or release notes with SDK upgrade summary - - **Status**: ✅ Added v1.4.35 entry to vscode-extension/CHANGELOG.md -- [X] T051a List all documentation files requiring updates with assigned owners/locations (FR-007) - - **Status**: ✅ Documented in impact-report.md and validation-plan.md -- [ ] T051b Archive/link SDK 0.5.0-preview.1 release notes sources for future audits (FR-008) - - **Status**: Skipped - SDK is pre-release, official release notes available on GitHub/NuGet -- [X] T052 PR description: Document bug/fix, tests, docs updated per bug-fixing-checklist - - **Status**: ✅ PR #301 updated with comprehensive description - ---- - -## Dependencies & Execution Order - -### Phase Dependencies - -- **Setup (Phase 1)**: No dependencies – can start immediately -- **Foundational (Phase 2)**: Depends on Setup – BLOCKS all user stories -- **User Story 1 (Phase 3)**: Depends on Foundational – MVP -- **User Story 2 (Phase 4)**: Depends on Foundational – can run in parallel with US1 -- **User Story 3 (Phase 5)**: Depends on Foundational – can run in parallel with US1/US2 -- **New Capability Adoption (Phase 6)**: Depends on Foundational – can run in parallel with user stories -- **.NET Best Practices (Phase 7)**: Depends on Foundational – can run in parallel with user stories -- **Polish (Phase 8)**: Depends on all prior phases - -### User Story Dependencies - -- **User Story 1 (P1)**: Can start after Foundational – independently testable -- **User Story 2 (P2)**: Can start after Foundational – independently testable (documentation only) -- **User Story 3 (P3)**: Can start after Foundational – independently testable (documentation only) - -### Within Each Phase - -- Tasks marked [P] can run in parallel -- Sequential tasks have implicit dependencies on prior tasks in same phase -- Build verification gates each major phase - -### Parallel Opportunities - -**After Foundational phase completes, these can run in parallel:** -- User Story 1 tests (T016-T019) -- User Story 2 documentation (T022-T027) -- User Story 3 documentation (T028-T032) -- New Capability Adoption (T033-T037) -- .NET Best Practices (T038-T045) - ---- - -## Parallel Example: Foundational Phase - -```text -# Sequential (dependency chain) -T004 → T005 → T015 - -# Parallel within phase -T006 | T007 | T008 | T009 (RequestOptions migration - different projects) -T010 | T011 | T012 | T013 | T014 (different fix types) -``` - ---- - -## Implementation Strategy - -### MVP First (User Story 1 Only) - -1. Complete Phase 1: Setup -2. Complete Phase 2: Foundational (CRITICAL) -3. Complete Phase 3: User Story 1 -4. **STOP and VALIDATE**: Build + tests pass → SDK upgrade functional -5. Can merge MVP at this checkpoint - -### Incremental Delivery - -1. Setup + Foundational → SDK compiles -2. User Story 1 → Tests pass → MVP ready -3. User Story 2 → Impact report complete -4. User Story 3 → Validation/rollback plan complete -5. Phase 6-7 → New capabilities + best practices adopted -6. Phase 8 → Polish and PR ready - ---- - -## Notes - -- [P] tasks = different files, no dependencies -- [Story] label maps task to specific user story for traceability -- Each user story independently completable and testable -- Commit after each task or logical group -- Stop at any checkpoint to validate independently -- Constitution gates: Zero warnings, PR workflow, no placeholders - ---- - -## Summary - -| Metric | Count | -|--------|-------| -| Total Tasks | 55 | -| Setup Phase | 3 | -| Foundational Phase | 12 | -| User Story 1 (P1) | 6 | -| User Story 2 (P2) | 6 | -| User Story 3 (P3) | 5 | -| New Capability Adoption | 6 | -| .NET Best Practices | 8 | -| Polish Phase | 9 | -| Parallel Opportunities | 35+ tasks marked [P] or can run with other stories | -| Independent Test Criteria | 3 (one per user story) | -| Suggested MVP Scope | User Story 1 (Phases 1-3, 21 tasks) | diff --git a/specs/001-upgrade-mcp-sdk/validation-plan.md b/specs/001-upgrade-mcp-sdk/validation-plan.md deleted file mode 100644 index ab901eaa..00000000 --- a/specs/001-upgrade-mcp-sdk/validation-plan.md +++ /dev/null @@ -1,169 +0,0 @@ -# Validation and Rollback Plan: MCP SDK 0.5.0-preview.1 Upgrade - -**Created**: 2025-12-14 -**Branch**: `001-upgrade-mcp-sdk` -**Target SDK**: `ModelContextProtocol` 0.5.0-preview.1 - ---- - -## Validation Checklist - -### Pre-Merge Validation - -| Step | Command | Expected Result | Gate | -|------|---------|-----------------|------| -| 1. Build | `dotnet build` | 0 warnings, 0 errors | ✅ PASS | -| 2. MCP Server Tests | `dotnet test tests/ExcelMcp.McpServer.Tests/` | 66/66 passing | ✅ PASS | -| 3. CLI Tests | `dotnet test tests/ExcelMcp.CLI.Tests/` | 2/2 passing | ✅ PASS | -| 4. Core Feature Tests | `dotnet test --filter "Feature=PowerQuery&RunType!=OnDemand"` | 49/49 passing | ✅ PASS | -| 5. Tables Feature Tests | `dotnet test --filter "Feature=Tables&RunType!=OnDemand"` | 20/20 passing | ✅ PASS | - -### Post-Merge Validation (CI/CD) - -| Step | Workflow | Expected Result | -|------|----------|-----------------| -| 1. PR Build | `build-mcp-server.yml` | Green ✅ | -| 2. PR Build | `build-cli.yml` | Green ✅ | -| 3. Integration Tests | `integration-tests.yml` | Green ✅ | -| 4. CodeQL | `codeql.yml` | No new security issues | - -### Manual Smoke Tests - -| Test | Procedure | Expected Result | -|------|-----------|-----------------| -| 1. Server Startup | `dotnet run --project src/ExcelMcp.McpServer` | Starts without error | -| 2. Claude Desktop | Connect via MCP config | Tools discovered | -| 3. file | Open test file | Session created | -| 4. worksheet | List sheets | Sheets returned | - ---- - -## Decision Gates - -### Go/No-Go Criteria - -| Gate | Criteria | Status | -|------|----------|--------| -| **BUILD** | 0 warnings, 0 errors | ✅ Required for merge | -| **TESTS** | No new test failures | ✅ Required for merge | -| **SECURITY** | No new CodeQL alerts | ✅ Required for merge | -| **REVIEW** | Approved by 1+ reviewer | ✅ Required for merge | - -### Acceptable Conditions - -| Condition | Decision | -|-----------|----------| -| Pre-existing test failures | ⚠️ Acceptable (documented in impact-report.md) | -| New SDK deprecation warnings | ⚠️ Acceptable if suppressed with justification | -| Preview package stability | ⚠️ Acceptable for development builds | - -### Blocking Conditions - -| Condition | Decision | -|-----------|----------| -| New test failures | ❌ BLOCK - Fix before merge | -| Build errors | ❌ BLOCK - Fix before merge | -| New security alerts | ❌ BLOCK - Assess severity | -| MCP tools not discoverable | ❌ BLOCK - Fix before merge | - ---- - -## Rollback Procedure - -### Trigger Conditions - -Rollback is required if ANY of the following occur after merge: - -1. **Build Failures**: `main` branch fails to build -2. **Test Regressions**: Previously passing tests fail -3. **Runtime Errors**: MCP server crashes or hangs -4. **Protocol Errors**: Clients cannot connect/discover tools - -### Rollback Steps - -#### Step 1: Immediate Mitigation - -```bash -# Create hotfix branch from main (pre-merge state) -git checkout main -git checkout -b hotfix/revert-sdk-upgrade - -# Revert the merge commit -git revert --no-edit - -# Push hotfix -git push origin hotfix/revert-sdk-upgrade -``` - -#### Step 2: Dependency Revert - -Edit `Directory.Packages.props`: - -```xml - - - - - -``` - -#### Step 3: Verification - -```bash -# Verify build -dotnet build - -# Verify tests -dotnet test tests/ExcelMcp.McpServer.Tests/ - -# Verify smoke test -dotnet run --project src/ExcelMcp.McpServer -``` - -#### Step 4: Communication - -1. **GitHub Issue**: Create issue documenting the failure -2. **PR Comment**: Add rollback details to original PR -3. **Team Notification**: Alert maintainers via GitHub mentions - ---- - -## Release Timeline - -| Phase | Target | Status | -|-------|--------|--------| -| Feature Branch | 2025-12-14 | ✅ Complete | -| Code Review | 2025-12-14 | 🔄 Pending | -| Merge to Main | After approval | ⏳ Pending | -| Release Tag | Next release | ⏳ Pending | - ---- - -## Sign-Off - -| Role | Name | Date | Approval | -|------|------|------|----------| -| Developer | GitHub Copilot | 2025-12-14 | ✅ Implemented | -| Reviewer | | | ⏳ Pending | -| Release Manager | | | ⏳ Pending | - ---- - -## Appendix: Files Changed - -| File | Change Type | Description | -|------|-------------|-------------| -| `Directory.Packages.props` | Modified | SDK version bump | -| `tests/.../McpServerIntegrationTests.cs` | Modified | API rename | -| `tests/.../McpServerSmokeTests.cs` | Modified | Test isolation | -| `tests/.../ExcelFileToolOperationTrackingTests.cs` | Modified | Test isolation | -| `tests/.../ProgramTransportTestCollection.cs` | Created | xUnit collection | -| `src/ExcelMcp.CLI/Commands/Sheet/SheetCommand.cs` | Modified | JSON output for mutations | -| `src/ExcelMcp.McpServer/Program.cs` | Modified | Exit code handling (0/1) | -| `src/ExcelMcp.Core/.../PivotTableCommands.Fields.cs` | Modified | stderr for warnings | -| `src/ExcelMcp.Core/.../PivotTableCommands.Lifecycle.cs` | Modified | stderr for warnings | -| `src/ExcelMcp.Core/.../RegularPivotTableFieldStrategy.cs` | Modified | stderr for warnings | -| `src/ExcelMcp.Core/.../OlapPivotTableFieldStrategy.cs` | Modified | stderr for warnings | -| `specs/001-upgrade-mcp-sdk/impact-report.md` | Created | Impact documentation | -| `specs/001-upgrade-mcp-sdk/validation-plan.md` | Created | This file | -| `specs/001-upgrade-mcp-sdk/tasks.md` | Modified | Task tracking | diff --git a/specs/003-datamodel-llm-guidance/checklists/requirements.md b/specs/003-datamodel-llm-guidance/checklists/requirements.md deleted file mode 100644 index c6ab3834..00000000 --- a/specs/003-datamodel-llm-guidance/checklists/requirements.md +++ /dev/null @@ -1,19 +0,0 @@ -# Specification Quality Checklist: Data Model LLM Guidance - -**Feature**: [spec.md](../spec.md) - -## Validation - -- [x] Problem is clear and specific -- [x] Solution is simple (2 deliverables) -- [x] Requirements are minimal and testable -- [x] No overengineering - -## Deliverables - -1. [x] Update `ExcelDataModelTool.cs` XML comments with warnings -2. [x] Create `src/ExcelMcp.McpServer/Prompts/Content/datamodel.md` - -## Ready for Implementation - -✅ Implementation complete - ready for PR diff --git a/specs/003-datamodel-llm-guidance/plan.md b/specs/003-datamodel-llm-guidance/plan.md deleted file mode 100644 index b2284c41..00000000 --- a/specs/003-datamodel-llm-guidance/plan.md +++ /dev/null @@ -1,19 +0,0 @@ -# Implementation Plan: Data Model LLM Guidance - -## Overview - -Simple documentation-only change: update tool description and create prompt file. - -## Tech Stack - -- C# XML comments (tool descriptions) -- Markdown (MCP prompt file) - -## Files to Modify/Create - -1. `src/ExcelMcp.McpServer/Tools/ExcelDataModelTool.cs` - Update XML summary -2. `src/ExcelMcp.McpServer/Prompts/Content/datamodel.md` - Create new file - -## No Tests Required - -This is documentation-only - no code logic changes. diff --git a/specs/003-datamodel-llm-guidance/spec.md b/specs/003-datamodel-llm-guidance/spec.md deleted file mode 100644 index 853809b8..00000000 --- a/specs/003-datamodel-llm-guidance/spec.md +++ /dev/null @@ -1,36 +0,0 @@ -# Feature Specification: Data Model LLM Guidance Enhancement - -**Feature Branch**: `003-datamodel-llm-guidance` -**Created**: December 14, 2025 -**Status**: Draft - -## Problem - -LLMs get stuck when Data Model operations fail. Their recovery instinct is to "start fresh" by deleting and recreating tables. **But deleting a table cascades to delete ALL its measures** - potentially hours of user work lost. - -Secondary issue: LLMs "forget" the MCP tools exist in long conversations and suggest manual Power Pivot steps instead. - -## Solution - -Two simple changes: - -1. **Update `datamodel` tool description** - Add clear warnings about destructive operations and a quick recovery guide -2. **Create `datamodel.md` prompt file** - Troubleshooting reference LLMs can consult when stuck - -## Requirements - -- **FR-001**: Tool description warns that delete-table also deletes all associated measures -- **FR-002**: Tool description includes quick recovery tips (use update-measure, check list-measures first) -- **FR-003**: Create prompt file with common error scenarios and non-destructive fixes - -## Success Criteria - -- **SC-001**: Tool description contains "DESTRUCTIVE" warning for delete-table -- **SC-002**: Prompt file exists with at least 5 common error recovery patterns -- **SC-003**: No delete-table suggestion in any recovery guidance - -## Out of Scope - -- Changing Excel COM behavior (cascade delete is baked in) -- Blocking delete operations (users may legitimately need them) -- Complex error response modifications (keep it simple) diff --git a/specs/003-datamodel-llm-guidance/tasks.md b/specs/003-datamodel-llm-guidance/tasks.md deleted file mode 100644 index 574c7ad4..00000000 --- a/specs/003-datamodel-llm-guidance/tasks.md +++ /dev/null @@ -1,11 +0,0 @@ -# Tasks: Data Model LLM Guidance - -## Setup -- [x] 1. Create plan.md - -## Implementation -- [x] 2. Update ExcelDataModelTool.cs XML comments with destructive operation warnings -- [x] 3. Create datamodel.md prompt file with recovery guidance - -## Validation -- [x] 4. Build project to verify no syntax errors diff --git a/specs/006-dotnet10-upgrade/checklists/requirements.md b/specs/006-dotnet10-upgrade/checklists/requirements.md deleted file mode 100644 index 27580500..00000000 --- a/specs/006-dotnet10-upgrade/checklists/requirements.md +++ /dev/null @@ -1,43 +0,0 @@ -# Specification Quality Checklist: .NET 10 Framework Upgrade - -**Purpose**: Validate specification completeness and quality before proceeding to planning -**Created**: 2025-12-28 -**Feature**: [spec.md](../spec.md) - -## Content Quality - -- [x] No implementation details (languages, frameworks, APIs) -- [x] Focused on user value and business needs -- [x] Written for non-technical stakeholders -- [x] All mandatory sections completed - -## Requirement Completeness - -- [x] No [NEEDS CLARIFICATION] markers remain -- [x] Requirements are testable and unambiguous -- [x] Success criteria are measurable -- [x] Success criteria are technology-agnostic (no implementation details) -- [x] All acceptance scenarios are defined -- [x] Edge cases are identified -- [x] Scope is clearly bounded -- [x] Dependencies and assumptions identified - -## Feature Readiness - -- [x] All functional requirements have clear acceptance criteria -- [x] User scenarios cover primary flows -- [x] Feature meets measurable outcomes defined in Success Criteria -- [x] No implementation details leak into specification - -## Validation Results - -### Pass Summary - -All checklist items pass. The specification is ready for `/speckit.clarify` or `/speckit.plan`. - -### Notes - -- **Assumption documented**: .NET 10 SDK availability (may be preview or GA depending on timing) -- **Assumption documented**: Package compatibility - will be validated during implementation -- **Constitution already updated**: `.specify/memory/constitution.md` was updated to v1.1.0 with .NET 10 requirement -- **Scope bounded**: VS Code extension explicitly excluded (TypeScript-based, no .NET dependency) diff --git a/specs/006-dotnet10-upgrade/plan.md b/specs/006-dotnet10-upgrade/plan.md deleted file mode 100644 index 89dff968..00000000 --- a/specs/006-dotnet10-upgrade/plan.md +++ /dev/null @@ -1,254 +0,0 @@ -# Implementation Plan: .NET 10 Framework Upgrade - -**Branch**: `006-dotnet10-upgrade` | **Date**: 2025-01-12 | **Spec**: [spec.md](spec.md) -**Input**: Feature specification from `/specs/006-dotnet10-upgrade/spec.md` - -## Summary - -Upgrade ExcelMcp solution from .NET 8 to .NET 10, updating SDK version, target framework across all 8 projects, CI/CD workflows, Docker images, and documentation. This is a configuration-only upgrade with no code changes required. - -## Technical Context - -**Language/Version**: C# 14 / .NET 10.0 (upgrading from C# 12 / .NET 8.0) -**Primary Dependencies**: ModelContextProtocol, Microsoft.Extensions.*, Application Insights -**Storage**: N/A (Excel files managed via COM) -**Testing**: xUnit integration tests with real Excel instances -**Target Platform**: Windows x64/ARM64 (COM interop requirement) -**Project Type**: Multi-project solution (4 src + 4 tests) -**Performance Goals**: No regression from .NET 8 baseline -**Constraints**: .NET 10 GA required (no preview), Windows-only (Excel COM) -**Scale/Scope**: 8 csproj files, 5 workflows, 7+ documentation files, 1 Dockerfile - -## Constitution Check - -*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* - -| Principle | Status | Notes | -|-----------|--------|-------| -| I. Success Flag Integrity | ✅ N/A | No code changes, configuration only | -| II. MCP JSON Contract | ✅ N/A | No API changes | -| III. Tool Descriptions | ✅ N/A | No tool changes | -| IV. COM Lifecycle | ✅ N/A | No COM code changes | -| V. Exception Propagation | ✅ N/A | No exception handling changes | -| VI. COM API First | ✅ Compliant | No new dependencies | -| VII. Integration-Only Testing | ✅ Compliant | Existing tests validate upgrade | -| VIII. Test File Isolation | ✅ N/A | No test changes | -| IX. Surgical Test Execution | ✅ Compliant | Will run feature tests only | -| X. Save Only for Persistence | ✅ N/A | No test changes | -| XI. PR Workflow | ✅ Required | Must use PR for this change | -| XII. Test Before Commit | ✅ Required | Must run integration tests | -| XIII. Never Commit Automatically | ✅ Required | User approval for all commits | -| XIV. Comprehensive Bug Fixes | ✅ N/A | Not a bug fix | -| XV. Check PR Review Comments | ✅ Required | Will fix automated comments | -| XVI. Core-MCP Coverage | ✅ N/A | No new commands | -| XVII. No Placeholders | ✅ Compliant | No TODOs introduced | -| XVIII. Trust IDE Warnings | ✅ Compliant | Will address any new warnings | - -**Gate Status**: ✅ PASSED - Proceed to implementation - -## Project Structure - -### Documentation (this feature) - -```text -specs/006-dotnet10-upgrade/ -├── spec.md # Feature specification ✅ -├── plan.md # This file ✅ -├── research.md # Phase 0 output (minimal - upgrade well-documented) -├── data-model.md # N/A - no data model changes -├── quickstart.md # N/A - no new APIs -├── contracts/ # N/A - no API changes -└── tasks.md # Phase 2 output (/speckit.tasks command) -``` - -### Source Code (repository root) - -```text -# Existing structure - no changes to layout -src/ -├── ExcelMcp.ComInterop/ # COM interop patterns -├── ExcelMcp.Core/ # Business logic -├── ExcelMcp.CLI/ # CLI tool -└── ExcelMcp.McpServer/ # MCP server - -tests/ -├── ExcelMcp.ComInterop.Tests/ -├── ExcelMcp.Core.Tests/ -├── ExcelMcp.CLI.Tests/ -└── ExcelMcp.McpServer.Tests/ -``` - -**Structure Decision**: No changes to project structure. This is a framework version upgrade only. - -## Complexity Tracking - -> No constitution violations. This is a straightforward configuration upgrade. ---- - -## Phase 0: Research - -### R-001: Verify .NET 10 GA SDK Version - -**Status**: ✅ Complete - -**Finding**: .NET 10 GA released November 2024. Latest SDK version for `global.json`: -- SDK Version: `10.0.100` -- Runtime Version: `10.0.0` - -**Source**: Microsoft .NET downloads page, winget package `Microsoft.DotNet.SDK.10` - -### R-002: Verify NuGet Package Compatibility - -**Status**: ✅ Complete - -**Finding**: All current dependencies support .NET 10: -- `ModelContextProtocol` - Targets `netstandard2.0+`, compatible -- `Microsoft.Extensions.Hosting` - .NET 10 compatible packages available -- `Microsoft.ApplicationInsights.WorkerService` - .NET 10 compatible -- `xunit` / `Moq` / test packages - .NET 10 compatible - -**No package version changes required** - existing versions support net10.0. - -### R-003: Verify Docker Base Images - -**Status**: ✅ Complete - -**Finding**: .NET 10 Docker images available: -- Build: `mcr.microsoft.com/dotnet/sdk:10.0` -- Runtime: `mcr.microsoft.com/dotnet/runtime:10.0` - -### R-004: Verify GitHub Actions .NET Setup - -**Status**: ✅ Complete - -**Finding**: `actions/setup-dotnet@v4` supports `dotnet-version: 10.0.x` - ---- - -## Phase 1: Design - -### No Design Required - -This upgrade involves **configuration changes only**. No new: -- Data models -- API contracts -- User interfaces -- Business logic - -### Files to Modify (Categorized) - -#### Category 1: SDK & Framework (Core Changes) - -| File | Current | Target | Requirement | -|------|---------|--------|-------------| -| `global.json` | `8.0.416` | `10.0.100` | FR-002 | -| `src/ExcelMcp.ComInterop/ExcelMcp.ComInterop.csproj` | `net8.0` | `net10.0` | FR-001 | -| `src/ExcelMcp.Core/ExcelMcp.Core.csproj` | `net8.0` | `net10.0` | FR-001 | -| `src/ExcelMcp.CLI/ExcelMcp.CLI.csproj` | `net8.0` | `net10.0` | FR-001 | -| `src/ExcelMcp.McpServer/ExcelMcp.McpServer.csproj` | `net8.0` | `net10.0` | FR-001 | -| `tests/ExcelMcp.ComInterop.Tests/ExcelMcp.ComInterop.Tests.csproj` | `net8.0` | `net10.0` | FR-001 | -| `tests/ExcelMcp.Core.Tests/ExcelMcp.Core.Tests.csproj` | `net8.0` | `net10.0` | FR-001 | -| `tests/ExcelMcp.CLI.Tests/ExcelMcp.CLI.Tests.csproj` | `net8.0` | `net10.0` | FR-001 | -| `tests/ExcelMcp.McpServer.Tests/ExcelMcp.McpServer.Tests.csproj` | `net8.0` | `net10.0` | FR-001 | - -#### Category 2: CI/CD Workflows - -| File | Change | Requirement | -|------|--------|-------------| -| `.github/workflows/build-mcp-server.yml` | `dotnet-version: 8.0.x` → `10.0.x`, paths `net8.0` → `net10.0` | FR-003, FR-004 | -| `.github/workflows/build-cli.yml` | `dotnet-version: 8.0.x` → `10.0.x`, paths `net8.0` → `net10.0` | FR-003, FR-004 | -| `.github/workflows/release-mcp-server.yml` | `dotnet-version: 8.0.x` → `10.0.x` | FR-003 | -| `.github/workflows/release-vscode-extension.yml` | `dotnet-version: 8.0.x` → `10.0.x` (if applicable) | FR-003 | -| `.github/workflows/codeql.yml` | `dotnet-version: 8.0.x` → `10.0.x` | FR-003 | - -#### Category 3: Container - -| File | Change | Requirement | -|------|--------|-------------| -| `Dockerfile` | Base images `sdk:8.0` → `sdk:10.0`, `runtime:8.0` → `runtime:10.0` | FR-007 | - -#### Category 4: Documentation - -| File | Change | Requirement | -|------|--------|-------------| -| `README.md` | Badge `.NET 8.0` → `.NET 10`, requirements section | FR-005, FR-006 | -| `docs/INSTALLATION.md` | .NET 10 requirement, winget command | FR-010, FR-011 | -| `src/ExcelMcp.McpServer/README.md` | .NET 10 requirement | FR-006 | -| `src/ExcelMcp.CLI/README.md` | .NET 10 requirement | FR-006 | -| `gh-pages/index.md` | .NET 10 requirement | FR-005, FR-006 | -| `gh-pages/installation.md` | .NET 10 requirement, winget command | FR-010, FR-011 | -| `vscode-extension/CHANGELOG.md` | Document .NET 10 requirement change | FR-015 | - -#### Category 5: Already Updated - -| File | Status | Notes | -|------|--------|-------| -| `.specify/memory/constitution.md` | ✅ Done | Updated to v1.1.0 with .NET 10 | - ---- - -## Implementation Order - -### Step 1: Core Framework Changes (Blocking) -1. Update `global.json` to SDK `10.0.100` -2. Update all 8 `.csproj` files to `net10.0` -3. Run `dotnet restore` to verify package compatibility -4. Run `dotnet build` to verify compilation -5. Run integration tests to verify functionality - -### Step 2: CI/CD Updates (Blocking for PR) -1. Update all workflow files to `dotnet-version: 10.0.x` -2. Update artifact paths from `net8.0` to `net10.0` - -### Step 3: Container Updates (Non-Blocking) -1. Update Dockerfile base images to .NET 10 - -### Step 4: Documentation Updates (Non-Blocking) -1. Update README.md badge and requirements -2. Update installation documentation with winget command -3. Update component READMEs -4. Update gh-pages documentation -5. Update VS Code extension CHANGELOG - -### Step 5: Verification -1. Build solution with 0 warnings -2. Run integration tests (excluding VBA) -3. Verify Docker build (if applicable) -4. Create PR and verify CI/CD passes - ---- - -## Success Verification Checklist - -| Criterion | Verification Command/Action | -|-----------|----------------------------| -| SC-001: Build 0 warnings | `dotnet build --configuration Release` | -| SC-002: Tests pass | `dotnet test --filter "Category=Integration&RunType!=OnDemand&Feature!=VBA"` | -| SC-003: Workflows pass | Check GitHub Actions after PR | -| SC-004: NuGet targets net10.0 | Inspect `.nupkg` contents | -| SC-005: Docs accurate | Manual review of all updated files | -| SC-006: Docker builds | `docker build -t test .` | -| SC-007: No new warnings | Verify build output | - ---- - -## Risk Assessment - -| Risk | Likelihood | Impact | Mitigation | -|------|------------|--------|------------| -| Package incompatibility | Low | Medium | Spec assumption verified - packages support net10.0 | -| CI/CD path issues | Low | Medium | Comprehensive path updates in workflow files | -| New compiler warnings | Low | Low | Address any C# 14 warnings as they arise | -| Docker image availability | Low | Low | Microsoft publishes images at GA | - ---- - -## Rollback Plan - -If critical issues discovered post-merge: -1. Revert `global.json` to `8.0.416` -2. Revert all `.csproj` files to `net8.0` -3. Revert workflow `dotnet-version` to `8.0.x` -4. Revert Dockerfile base images to `8.0` - -All changes are configuration-only, making rollback straightforward. diff --git a/specs/006-dotnet10-upgrade/research.md b/specs/006-dotnet10-upgrade/research.md deleted file mode 100644 index 0a45f1c6..00000000 --- a/specs/006-dotnet10-upgrade/research.md +++ /dev/null @@ -1,127 +0,0 @@ -# Research: .NET 10 Framework Upgrade - -**Branch**: `006-dotnet10-upgrade` | **Date**: 2025-01-12 - -## Overview - -This document captures research findings for upgrading ExcelMcp from .NET 8 to .NET 10. Since .NET 10 is a standard Long-Term Support (LTS) release with well-documented migration paths, research requirements are minimal. - ---- - -## R-001: .NET 10 GA SDK Version - -**Question**: What is the correct SDK version for `global.json`? - -**Decision**: Use SDK version `10.0.100` - -**Rationale**: -- .NET 10 GA released November 2024 -- Version `10.0.100` is the initial GA SDK release -- `rollForward: latestFeature` allows automatic updates to patch releases - -**Alternatives Considered**: -- Preview versions: Rejected per spec constraint "GA only, no preview" -- Specific patch version: Rejected in favor of `latestFeature` rollforward - -**Source**: [.NET 10 Download](https://dotnet.microsoft.com/download/dotnet/10.0) - ---- - -## R-002: NuGet Package Compatibility - -**Question**: Do existing NuGet dependencies support .NET 10? - -**Decision**: All packages compatible, no version changes needed - -**Rationale**: -- `ModelContextProtocol` targets `netstandard2.0+` (compatible with all .NET versions) -- `Microsoft.Extensions.*` packages have .NET 10 specific builds -- Test packages (`xunit`, `Moq`) support all modern .NET versions - -**Verification Steps**: -1. Run `dotnet restore` after updating target framework -2. Build should succeed without package errors -3. If issues, update to latest package versions - ---- - -## R-003: Docker Base Images - -**Question**: Are .NET 10 Docker images available? - -**Decision**: Use official Microsoft container images - -**Rationale**: -- Microsoft publishes images same day as GA release -- SDK image: `mcr.microsoft.com/dotnet/sdk:10.0` -- Runtime image: `mcr.microsoft.com/dotnet/runtime:10.0` - -**Source**: [.NET Container Images](https://hub.docker.com/_/microsoft-dotnet) - ---- - -## R-004: GitHub Actions Setup - -**Question**: Does `actions/setup-dotnet` support .NET 10? - -**Decision**: Use `dotnet-version: 10.0.x` with `actions/setup-dotnet@v4` - -**Rationale**: -- GitHub Actions `setup-dotnet@v4` supports all released .NET versions -- Pattern `10.0.x` installs latest available patch version - -**Source**: [setup-dotnet Action](https://github.com/actions/setup-dotnet) - ---- - -## R-005: C# 14 Language Features - -**Question**: What C# 14 features could improve the codebase? - -**Decision**: Document in spec, implement as separate follow-up work - -**Rationale**: -- Spec explicitly states: "No code changes required for upgrade" -- C# 14 features are optional enhancements, not upgrade requirements -- Features like `field` keyword, extension types, and improved `params` are beneficial but scope creep - -**Features Identified** (for future consideration): -- `field` keyword in properties -- Extension types (replacing static extension methods) -- `params` with Span types -- Improved pattern matching -- Lock object improvements - -**Source**: [C# 14 What's New](https://learn.microsoft.com/dotnet/csharp/whats-new/csharp-14) - ---- - -## R-006: Breaking Changes - -**Question**: Are there any .NET 10 breaking changes affecting ExcelMcp? - -**Decision**: No blocking breaking changes identified - -**Rationale**: -- ExcelMcp uses COM interop (unchanged between .NET versions) -- No deprecated APIs used that are removed in .NET 10 -- `TreatWarningsAsErrors=true` will surface any issues during build - -**Verification**: Build with .NET 10 and address any warnings/errors - -**Source**: [.NET 10 Breaking Changes](https://learn.microsoft.com/dotnet/core/compatibility/10.0) - ---- - -## Summary - -| Research Item | Status | Action | -|---------------|--------|--------| -| SDK Version | ✅ Complete | Use `10.0.100` | -| NuGet Packages | ✅ Complete | No changes needed | -| Docker Images | ✅ Complete | Update to `:10.0` tags | -| GitHub Actions | ✅ Complete | Use `10.0.x` pattern | -| C# 14 Features | ✅ Complete | Document for future, out of scope | -| Breaking Changes | ✅ Complete | None affecting ExcelMcp | - -**All research items resolved. Proceed to implementation.** diff --git a/specs/006-dotnet10-upgrade/spec.md b/specs/006-dotnet10-upgrade/spec.md deleted file mode 100644 index fcbf770d..00000000 --- a/specs/006-dotnet10-upgrade/spec.md +++ /dev/null @@ -1,231 +0,0 @@ -# Feature Specification: .NET 10 Framework Upgrade - -**Feature Branch**: `006-dotnet10-upgrade` -**Created**: 2025-12-28 -**Status**: Draft -**Input**: User description: "Upgrade the project from .NET 8 to .NET 10 target framework" - -## User Scenarios & Testing *(mandatory)* - -### User Story 1 - Developer Builds Project with .NET 10 SDK (Priority: P1) - -As a developer, I want to build the ExcelMcp solution using .NET 10 SDK so that I can take advantage of the latest runtime improvements, language features, and long-term support. - -**Why this priority**: This is the core upgrade - without successful builds, no other stories can be tested or delivered. All downstream functionality depends on the project compiling and running on .NET 10. - -**Independent Test**: Clone repository, ensure .NET 10 SDK is installed, run `dotnet build` and verify all projects compile with zero warnings and zero errors. - -**Acceptance Scenarios**: - -1. **Given** .NET 10 SDK is installed, **When** developer runs `dotnet build` at solution root, **Then** all 8 projects (4 src + 4 tests) compile successfully with 0 warnings -2. **Given** .NET 10 SDK is installed, **When** developer runs `dotnet test --filter "Category=Integration&RunType!=OnDemand&Feature!=VBA"`, **Then** all integration tests pass -3. **Given** a fresh clone of the repository, **When** developer runs `dotnet restore`, **Then** all NuGet packages restore successfully with compatible .NET 10 versions - ---- - -### User Story 2 - CI/CD Pipeline Builds and Tests on .NET 10 (Priority: P2) - -As a maintainer, I want GitHub Actions workflows to use .NET 10 SDK so that automated builds and releases use the new target framework. - -**Why this priority**: CI/CD validation is critical for quality gates and ensures all contributors use the correct SDK version. - -**Independent Test**: Push a commit to trigger CI workflows and verify all workflow jobs complete successfully with .NET 10. - -**Acceptance Scenarios**: - -1. **Given** code is pushed to `main` branch, **When** `build-mcp-server.yml` workflow runs, **Then** it uses .NET 10 SDK and builds successfully -2. **Given** code is pushed to `main` branch, **When** `build-cli.yml` workflow runs, **Then** it uses .NET 10 SDK and builds successfully -3. **Given** a release tag is created, **When** `release-mcp-server.yml` workflow runs, **Then** NuGet packages are published with `net10.0` target framework -4. **Given** workflow artifact verification step runs, **When** checking for built executables, **Then** paths reference `net10.0` folder instead of `net8.0` - ---- - -### User Story 3 - End Users Run MCP Server and CLI on .NET 10 Runtime (Priority: P3) - -As an end user, I want the published MCP Server and CLI to run on .NET 10 runtime so that I benefit from performance improvements and modern runtime features. - -**Why this priority**: This affects the distributed artifacts but depends on successful builds and CI/CD updates first. - -**Independent Test**: Install published NuGet package or download release assets, run the tools, and verify they execute correctly. - -**Acceptance Scenarios**: - -1. **Given** user installs `Sbroenne.ExcelMcp.McpServer` NuGet package, **When** they run the server, **Then** it starts successfully and responds to MCP protocol requests -2. **Given** user installs `Sbroenne.ExcelMcp.CLI` NuGet package, **When** they run `excelcli --help`, **Then** help text displays correctly -3. **Given** README and installation docs reference .NET version, **When** user reads documentation, **Then** they see .NET 10 as the required runtime with winget installation command - ---- - -### User Story 4 - Users Upgrading from Previous Versions (Priority: P3) - -As a user upgrading from a previous version of ExcelMcp, I want clear instructions on how to install .NET 10 runtime so that I can continue using the tools without issues. - -**Why this priority**: Existing users need a smooth upgrade path with clear instructions to avoid confusion. - -**Independent Test**: Follow release notes instructions to install .NET 10 via winget and verify the upgraded tools work. - -**Acceptance Scenarios**: - -1. **Given** user has previous ExcelMcp version with .NET 8, **When** they read the release notes, **Then** they see clear instructions to install .NET 10 runtime via winget -2. **Given** user runs `winget install Microsoft.DotNet.Runtime.10`, **When** installation completes, **Then** .NET 10 runtime is available and ExcelMcp tools work correctly - ---- - -### Edge Cases - -- What happens when a developer has only .NET 8 SDK installed but not .NET 10? - - Build should fail with a clear error message indicating .NET 10 SDK is required -- What happens when NuGet packages have .NET 10-incompatible dependencies? - - Package restore should fail with clear dependency resolution errors -- How does the system handle the Dockerfile which uses .NET base images? - - Dockerfile must be updated to use `mcr.microsoft.com/dotnet/runtime:10.0` or appropriate .NET 10 image - -## Requirements *(mandatory)* - -### Functional Requirements - -- **FR-001**: All `.csproj` files MUST specify `net10.0` -- **FR-002**: `global.json` MUST specify the latest .NET 10 GA SDK version (e.g., `10.0.100`) - preview versions are NOT permitted -- **FR-003**: All GitHub Actions workflows MUST use `dotnet-version: 10.0.x` in `setup-dotnet` action -- **FR-004**: All workflow artifact paths referencing `net8.0` MUST be updated to `net10.0` -- **FR-005**: `README.md` badge MUST display ".NET 10" instead of ".NET 8.0" -- **FR-006**: `README.md` requirements section MUST state ".NET 10 runtime" requirement -- **FR-007**: `Dockerfile` MUST use .NET 10 base images for build and runtime stages -- **FR-008**: All NuGet package dependencies MUST be compatible with .NET 10 or updated to compatible versions -- **FR-009**: Constitution file (`.specify/memory/constitution.md`) MUST reflect .NET 10 SDK requirement -- **FR-010**: `docs/INSTALLATION.md` MUST reference .NET 10 runtime requirement -- **FR-011**: `docs/INSTALLATION.md` MUST include winget installation command for .NET 10 (e.g., `winget install Microsoft.DotNet.Runtime.10`) -- **FR-012**: Project MUST build with zero warnings after upgrade (preserving `TreatWarningsAsErrors=true`) -- **FR-013**: All existing integration tests MUST pass after upgrade -- **FR-014**: Release notes MUST document the .NET 10 upgrade and include winget installation instructions for users upgrading from previous versions -- **FR-015**: `vscode-extension/CHANGELOG.md` MUST document the .NET 10 runtime requirement change for the next VS Code extension release - -### Files Requiring Changes - -| Category | Files | -|----------|-------| -| SDK Version | `global.json` | -| Target Framework | `src/ExcelMcp.ComInterop/ExcelMcp.ComInterop.csproj`, `src/ExcelMcp.Core/ExcelMcp.Core.csproj`, `src/ExcelMcp.CLI/ExcelMcp.CLI.csproj`, `src/ExcelMcp.McpServer/ExcelMcp.McpServer.csproj`, `tests/ExcelMcp.ComInterop.Tests/ExcelMcp.ComInterop.Tests.csproj`, `tests/ExcelMcp.Core.Tests/ExcelMcp.Core.Tests.csproj`, `tests/ExcelMcp.CLI.Tests/ExcelMcp.CLI.Tests.csproj`, `tests/ExcelMcp.McpServer.Tests/ExcelMcp.McpServer.Tests.csproj` | -| CI/CD Workflows | `.github/workflows/build-mcp-server.yml`, `.github/workflows/build-cli.yml`, `.github/workflows/release-mcp-server.yml`, `.github/workflows/release-vscode-extension.yml`, `.github/workflows/codeql.yml` | -| Documentation | `README.md`, `docs/INSTALLATION.md`, `src/ExcelMcp.McpServer/README.md`, `src/ExcelMcp.CLI/README.md`, `gh-pages/index.md`, `gh-pages/installation.md`, `vscode-extension/CHANGELOG.md` (release notes) | -| Container | `Dockerfile` | -| Constitution | `.specify/memory/constitution.md` (already updated to 1.1.0) | - -## Constraints - -- **.NET 10 GA Required**: This upgrade MUST use the latest .NET 10 GA (General Availability) release. Preview versions are NOT permitted. ✅ .NET 10 GA is now available (released November 2025). - -## Assumptions -- **Package compatibility**: All current NuGet dependencies are assumed compatible with .NET 10. If incompatibilities arise during implementation, packages will be updated to compatible versions. -- **No breaking API changes**: The upgrade is expected to be a drop-in replacement with no code changes required beyond configuration files. -- **VS Code Extension unchanged**: The VS Code extension (TypeScript-based) does not depend on .NET version and requires no changes. - -## C# 14 Code Improvement Opportunities *(optional - post-upgrade enhancements)* - -The .NET 10 SDK includes C# 14 with several language features that could improve the ExcelMcp codebase. These are **optional enhancements** that can be addressed in follow-up work after the core framework upgrade is complete. - -### High-Value Features - -| Feature | Applicability | Where to Use | Benefit | -|---------|--------------|--------------|---------| -| **`field` keyword** | ⭐⭐⭐ HIGH | `ResultBase`, `OperationResult` classes | Enforce Critical Rule #1 invariant (Success=true ⟹ ErrorMessage empty) at property level | -| **Extension members** | ⭐⭐⭐ HIGH | `ActionExtensions.cs` (12 action enums) | Cleaner extension properties instead of static methods | -| **Partial constructors** | ⭐⭐ MEDIUM | Large partial classes (`RangeCommands`, `TableCommands`, `PivotTableCommands`) | Consistent initialization across partial files | - -### Detailed Analysis - -#### 1. `field` Keyword for Property Invariants - -**Current Pattern** (violates Critical Rule #1 if misused): -```csharp -public class ResultBase { - public bool Success { get; set; } - public string? ErrorMessage { get; set; } -} -// No enforcement: Success=true with ErrorMessage="error" is possible -``` - -**With C# 14 `field` Keyword**: -```csharp -public class ResultBase { - public bool Success { - get => field; - set { - if (value && !string.IsNullOrEmpty(ErrorMessage)) - throw new InvalidOperationException("Success cannot be true when ErrorMessage is set"); - field = value; - } - } - public string? ErrorMessage { - get => field; - set { - if (Success && !string.IsNullOrEmpty(value)) - throw new InvalidOperationException("ErrorMessage cannot be set when Success is true"); - field = value; - } - } -} -``` - -**Impact**: Enforces invariant at compile-time property access, eliminating the class of bugs caught by Rule #1. - -**Files affected**: `src/ExcelMcp.Core/Models/ResultTypes.cs` (40+ result classes inherit from `ResultBase`) - -#### 2. Extension Members for Action Extensions - -**Current Pattern**: -```csharp -public static class ActionExtensions { - public static string ToActionString(this FileAction action) => action switch { ... }; - public static string ToActionString(this PowerQueryAction action) => action switch { ... }; - // 12 total extension methods -} -``` - -**With C# 14 Extension Members**: -```csharp -extension(FileAction action) { - public string ActionString => action switch { ... }; -} -extension(PowerQueryAction action) { - public string ActionString => action switch { ... }; -} -// Cleaner property-like access: action.ActionString instead of action.ToActionString() -``` - -**Impact**: More idiomatic property access, reduced verbosity, natural member syntax. - -**Files affected**: `src/ExcelMcp.McpServer/Models/ActionExtensions.cs` - -### Lower Priority Features - -| Feature | Applicability | Notes | -|---------|--------------|-------| -| **Null-conditional assignment** | ⭐⭐ MEDIUM | Already using `??=` pattern in 6 places; new `?.=` could help with COM object property assignments | -| **Lambda parameter modifiers** | ⭐⭐ MEDIUM | `batch.Execute()` callbacks could use `ref`/`in` for performance-critical paths | -| **Implicit Span conversions** | ⭐ LOW | No significant string buffer operations in current codebase | -| **`nameof` with unbound generics** | ⭐ LOW | Limited use cases identified | - -### Implementation Recommendation - -1. **Phase 1 (This Upgrade)**: Complete the .NET 10 framework upgrade with no code changes -2. **Phase 2 (Follow-up Spec)**: Create separate feature spec for "C# 14 Code Modernization" addressing: - - `field` keyword adoption for Result types - - Extension members migration for Action enums - - Partial constructor implementation - -This phased approach ensures the framework upgrade is clean and testable, with code modernization as a separate, lower-risk effort. - ---- - -## Success Criteria *(mandatory)* - -### Measurable Outcomes - -- **SC-001**: Solution builds successfully with 0 warnings and 0 errors on .NET 10 SDK -- **SC-002**: All integration tests pass (excluding VBA tests which require manual environment setup) -- **SC-003**: All GitHub Actions workflows pass on the upgrade PR -- **SC-004**: Published NuGet packages target `net10.0` framework -- **SC-005**: All documentation accurately reflects .NET 10 requirement -- **SC-006**: Dockerfile builds and runs successfully with .NET 10 base images -- **SC-007**: No new analyzer warnings introduced by the upgrade diff --git a/specs/006-dotnet10-upgrade/tasks.md b/specs/006-dotnet10-upgrade/tasks.md deleted file mode 100644 index 29b19807..00000000 --- a/specs/006-dotnet10-upgrade/tasks.md +++ /dev/null @@ -1,193 +0,0 @@ -# Tasks: .NET 10 Framework Upgrade - -**Input**: Design documents from `/specs/006-dotnet10-upgrade/` -**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅ - -**Tests**: Not required for this feature (configuration-only upgrade, validated by existing integration tests) - -**Organization**: Tasks are grouped by user story from spec.md to enable independent verification. - -## Format: `[ID] [P?] [Story?] Description` - -- **[P]**: Can run in parallel (different files, no dependencies) -- **[Story]**: Which user story this task belongs to (US1, US2, US3, US4) -- Exact file paths included in descriptions - ---- - -## Phase 1: Setup - -**Purpose**: Ensure prerequisites are met before making changes - -- [x] T001 Verify .NET 10 SDK is installed locally by running `dotnet --list-sdks` -- [x] T002 Create feature branch `006-dotnet10-upgrade` from main (if not already on branch) - ---- - -## Phase 2: User Story 1 - Developer Builds Project with .NET 10 SDK (Priority: P1) 🎯 MVP - -**Goal**: All projects compile and tests pass with .NET 10 SDK - -**Independent Test**: Run `dotnet build` and `dotnet test --filter "Category=Integration&RunType!=OnDemand&Feature!=VBA"` and verify 0 warnings, all tests pass - -### Implementation for User Story 1 - -- [x] T003 [US1] Update SDK version in global.json from `8.0.416` to `10.0.100` -- [x] T004 [P] [US1] Update TargetFramework to net10.0 in src/ExcelMcp.ComInterop/ExcelMcp.ComInterop.csproj -- [x] T005 [P] [US1] Update TargetFramework to net10.0 in src/ExcelMcp.Core/ExcelMcp.Core.csproj -- [x] T006 [P] [US1] Update TargetFramework to net10.0 in src/ExcelMcp.CLI/ExcelMcp.CLI.csproj -- [x] T007 [P] [US1] Update TargetFramework to net10.0 in src/ExcelMcp.McpServer/ExcelMcp.McpServer.csproj -- [x] T008 [P] [US1] Update TargetFramework to net10.0 in tests/ExcelMcp.ComInterop.Tests/ExcelMcp.ComInterop.Tests.csproj -- [x] T009 [P] [US1] Update TargetFramework to net10.0 in tests/ExcelMcp.Core.Tests/ExcelMcp.Core.Tests.csproj -- [x] T010 [P] [US1] Update TargetFramework to net10.0 in tests/ExcelMcp.CLI.Tests/ExcelMcp.CLI.Tests.csproj -- [x] T011 [P] [US1] Update TargetFramework to net10.0 in tests/ExcelMcp.McpServer.Tests/ExcelMcp.McpServer.Tests.csproj -- [x] T012 [US1] Run `dotnet restore` to verify all NuGet packages resolve correctly -- [x] T013 [US1] Run `dotnet build --configuration Release` and verify 0 warnings, 0 errors -- [x] T014 [US1] Run integration tests: `dotnet test --filter "Category=Integration&RunType!=OnDemand&Feature!=VBA"` - -**Checkpoint**: User Story 1 complete - project builds and tests pass on .NET 10 - ---- - -## Phase 3: User Story 2 - CI/CD Pipeline Builds and Tests on .NET 10 (Priority: P2) - -**Goal**: All GitHub Actions workflows use .NET 10 SDK and build successfully - -**Independent Test**: Push commit, verify all workflow jobs complete successfully - -### Implementation for User Story 2 - -- [x] T015 [P] [US2] Update dotnet-version from 8.0.x to 10.0.x in .github/workflows/build-mcp-server.yml -- [x] T016 [P] [US2] Update artifact paths from net8.0 to net10.0 in .github/workflows/build-mcp-server.yml -- [x] T017 [P] [US2] Update dotnet-version from 8.0.x to 10.0.x in .github/workflows/build-cli.yml -- [x] T018 [P] [US2] Update artifact paths from net8.0 to net10.0 in .github/workflows/build-cli.yml -- [x] T019 [P] [US2] Update dotnet-version from 8.0.x to 10.0.x in .github/workflows/release-mcp-server.yml -- [x] T020 [P] [US2] Update dotnet-version from 8.0.x to 10.0.x in .github/workflows/release-vscode-extension.yml (if applicable) -- [x] T021 [P] [US2] Update dotnet-version from 8.0.x to 10.0.x in .github/workflows/codeql.yml - -**Checkpoint**: User Story 2 complete - CI/CD workflows configured for .NET 10 - ---- - -## Phase 4: User Story 3 - End Users Run MCP Server and CLI on .NET 10 Runtime (Priority: P3) - -**Goal**: Published tools run on .NET 10 runtime, documentation is accurate - -**Independent Test**: Build NuGet package, verify targets net10.0; review documentation for accuracy - -### Container Update - -- [x] T022 [US3] Update Dockerfile FROM statements: sdk:8.0 → sdk:10.0, runtime:8.0 → runtime:10.0 - -### Documentation Updates - -- [x] T023 [P] [US3] Update .NET version badge from ".NET 8.0" to ".NET 10" in README.md -- [x] T024 [P] [US3] Update requirements section to state ".NET 10 runtime" in README.md -- [x] T025 [P] [US3] Update .NET version requirements in src/ExcelMcp.McpServer/README.md -- [x] T026 [P] [US3] Update .NET version requirements in src/ExcelMcp.CLI/README.md -- [x] T027 [P] [US3] Update .NET 10 requirement in gh-pages/index.md -- [x] T028 [P] [US3] Update .NET 10 requirement in gh-pages/installation.md - -**Checkpoint**: User Story 3 complete - container and documentation updated - ---- - -## Phase 5: User Story 4 - Users Upgrading from Previous Versions (Priority: P3) - -**Goal**: Clear upgrade instructions with winget installation command - -**Independent Test**: Follow instructions in docs to install .NET 10 via winget - -### Implementation for User Story 4 - -- [x] T029 [US4] Update docs/INSTALLATION.md with .NET 10 requirement and winget command: `winget install Microsoft.DotNet.Runtime.10` -- [x] T030 [US4] Add winget installation instructions to gh-pages/installation.md -- [x] T031 [US4] Add entry to vscode-extension/CHANGELOG.md documenting .NET 10 runtime requirement change - -**Checkpoint**: User Story 4 complete - upgrade path documented - ---- - -## Phase 6: Verification & PR - -**Purpose**: Final validation before creating pull request - -- [x] T032 Run `dotnet build --configuration Release` and confirm 0 warnings, 0 errors -- [x] T033 Run integration tests: `dotnet test --filter "Category=Integration&RunType!=OnDemand&Feature!=VBA"` -- [x] T034 Verify Docker build (optional): `docker build -t excelmcp-test .` -- [x] T035 Review all modified files for accuracy and completeness -- [ ] T036 Create pull request with comprehensive description -- [ ] T037 Check and fix any automated PR review comments (Copilot, GitHub Security) -- [ ] T038 Verify all GitHub Actions workflows pass on PR - ---- - -## Dependencies & Execution Order - -### Phase Dependencies - -- **Setup (Phase 1)**: No dependencies - verify prerequisites first -- **User Story 1 (Phase 2)**: Depends on Setup - CORE UPGRADE (MVP) -- **User Story 2 (Phase 3)**: Depends on User Story 1 (workflows need correct target framework) -- **User Story 3 (Phase 4)**: Can start after User Story 1 (documentation can parallel User Story 2) -- **User Story 4 (Phase 5)**: Can start after User Story 1 (documentation can parallel others) -- **Verification (Phase 6)**: Depends on all user stories complete - -### Within Each User Story - -- T003 (global.json) must complete before T004-T011 (csproj files) -- T004-T011 (csproj files) can all run in parallel -- T012-T014 (verification) must follow T003-T011 -- Documentation tasks (T023-T031) can all run in parallel - -### Parallel Opportunities - -```text -# After T003 (global.json), all csproj updates can run together: -T004, T005, T006, T007, T008, T009, T010, T011 - -# All workflow updates can run in parallel: -T015, T016, T017, T018, T019, T020, T021 - -# All documentation updates can run in parallel: -T023, T024, T025, T026, T027, T028, T029, T030, T031 -``` - ---- - -## Implementation Strategy - -### MVP First (User Story 1 Only) - -1. Complete Setup (T001-T002) -2. Complete User Story 1 (T003-T014) -3. **STOP and VALIDATE**: Build + tests pass locally -4. Can deploy/demo .NET 10 builds immediately - -### Full Delivery - -1. User Story 1: Core framework upgrade (blocking) -2. User Story 2: CI/CD updates (parallel with US3/US4 documentation) -3. User Story 3 + 4: Documentation updates (can run in parallel) -4. Verification: Final checks and PR - -### Total Task Count - -| Phase | Tasks | Parallelizable | -|-------|-------|----------------| -| Setup | 2 | 0 | -| User Story 1 (P1) | 12 | 8 | -| User Story 2 (P2) | 7 | 7 | -| User Story 3 (P3) | 7 | 6 | -| User Story 4 (P3) | 3 | 0 | -| Verification | 7 | 0 | -| **Total** | **38** | **21** | - ---- - -## Notes - -- All [P] tasks can run in parallel (different files, no dependencies) -- No new tests required - existing integration tests validate the upgrade -- Rollback plan documented in plan.md if critical issues discovered -- Constitution already updated to v1.1.0 with .NET 10 SDK requirement diff --git a/specs/CALCULATION-MODE-API-SPECIFICATION.md b/specs/CALCULATION-MODE-API-SPECIFICATION.md deleted file mode 100644 index f23b96d9..00000000 --- a/specs/CALCULATION-MODE-API-SPECIFICATION.md +++ /dev/null @@ -1,898 +0,0 @@ -# Calculation Mode API Specification - -> **User-controllable calculation mode for performance optimization and workflow control** -> -> **🤖 Primary Audience:** LLMs using MCP Server/CLI to automate Excel workbooks with complex formulas, Data Models, or bulk operations - -## What This Spec Provides (For LLMs) - -This specification defines a **Calculation Mode API** that lets you (an LLM) explicitly control when Excel recalculates formulas. This unlocks: - -### **Performance Optimization** - Batch Without Overhead -- Current state: Every write operation toggles calculation mode internally (50 writes = 50 toggles) -- With this API: Set manual mode once → 50 writes → Calculate once → ~10-50% faster - -### **Predictable Timing** - Know Exactly When Recalc Happens -- No surprise delays mid-operation -- No COM timeouts from unexpected DAX/Data Model recalculations -- Better token efficiency (fewer retries, fewer status checks) - -### **Preview Before Commit** - Validate Formulas Without Side Effects -- Write formulas → Read them back → Verify correct → THEN calculate -- Useful for complex financial models, debugging formula chains - -### **Step-Through Debugging** - Systematic Formula Analysis -- Change one input → Recalculate → Check dependent cells → Repeat -- Methodical debugging impossible with automatic mode - ---- - -## Background: Current State - -### Internal Optimization (Invisible to LLMs) - -The server already uses calculation mode internally to prevent COM timeouts: - -```csharp -// Current pattern in RangeCommands.Values.cs, RangeCommands.Formulas.cs, etc. -public OperationResult SetValues(IExcelBatch batch, string sheetName, string rangeAddress, List> values) -{ - return batch.Execute((ctx, ct) => - { - int originalCalculation = ctx.App.Calculation; // Save - ctx.App.Calculation = -4135; // xlCalculationManual - - try - { - range.Value2 = arrayValues; // Write without triggering recalc - } - finally - { - ctx.App.Calculation = originalCalculation; // Restore (triggers recalc) - } - }); -} -``` - -**Problem:** This per-operation toggling: -1. Adds overhead on every write (even if user wants many writes before recalc) -2. Triggers recalculation after EVERY operation (expensive for Data Model workbooks) -3. Invisible to LLM - can't be optimized at workflow level - -### CHANGELOG Reference (Issue #412) - -```markdown -- **COM Timeout with Data Model Dependencies** (#412): Fixed timeout when setting - formulas/values that trigger Data Model recalculation - - ROOT CAUSE: Excel's automatic calculation blocks COM interface during DAX recalculation - - FIX: Temporarily disable calculation mode (xlCalculationManual) during write operations -``` - ---- - -## Research: Excel Calculation Modes - -### Application.Calculation Property - -**Excel COM API:** -- `Application.Calculation` - Get/set calculation mode -- Type: `XlCalculation` enum (integer values) - -**Calculation Modes:** - -| Mode | Value | Behavior | -|------|-------|----------| -| `xlCalculationAutomatic` | -4105 | Recalculates when any value changes (default) | -| `xlCalculationManual` | -4135 | Only recalculates when explicitly requested | -| `xlCalculationSemiautomatic` | 2 | Auto except data tables (recalc-intensive) | - -**Official Reference:** -- [Application.Calculation Property](https://learn.microsoft.com/en-us/office/vba/api/excel.application.calculation) -- [XlCalculation Enumeration](https://learn.microsoft.com/en-us/office/vba/api/excel.xlcalculation) - -### Triggering Calculation - -**Scope Options:** - -| Method | Scope | Use Case | -|--------|-------|----------| -| `Application.Calculate()` | All open workbooks | After batch operations across files | -| `Workbook.Calculate()` | Single workbook (undocumented but works) | Not recommended - use Application | -| `Worksheet.Calculate()` | Single sheet | Targeted recalc after sheet changes | -| `Range.Calculate()` | Specific range | Surgical recalc for formula debugging | - -**Official Reference:** -- [Application.Calculate Method](https://learn.microsoft.com/en-us/office/vba/api/excel.application.calculate) -- [Worksheet.Calculate Method](https://learn.microsoft.com/en-us/office/vba/api/excel.worksheet.calculate) -- [Range.Calculate Method](https://learn.microsoft.com/en-us/office/vba/api/excel.range.calculate) - -### Calculation State - -**Properties to track:** - -| Property | Type | Purpose | -|----------|------|---------| -| `Application.CalculationState` | `XlCalculationState` | Current calculation status | -| - `xlDone` (0) | | Calculation complete | -| - `xlPending` (-4108) | | Calculation needed (dirty cells exist) | -| - `xlCalculating` (1) | | Currently calculating | - -**Official Reference:** -- [Application.CalculationState Property](https://learn.microsoft.com/en-us/office/vba/api/excel.application.calculationstate) - ---- - -## Proposed API Design - -### New Tool: `calculation_mode` - -A dedicated tool for calculation mode control, separate from existing tools. - -### Actions - -#### 1. `get-mode` - Query Current Calculation Mode - -**Purpose:** Check current mode and whether calculation is pending. - -**Parameters:** None (applies to Excel Application) - -**Returns:** -```json -{ - "success": true, - "mode": "automatic", - "modeValue": -4105, - "calculationState": "done", - "calculationStateValue": 0, - "isPending": false -} -``` - -**Use Case:** Before starting batch operations, check if already in manual mode (avoid redundant toggle). - ---- - -#### 2. `set-mode` - Set Calculation Mode - -**Purpose:** Switch between automatic, manual, or semi-automatic calculation. - -**Parameters:** - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `mode` | string | Yes | `automatic`, `manual`, or `semi-automatic` | - -**Mode Details:** - -| Mode | When to Use | -|------|-------------| -| `automatic` | Normal operation (default) - formulas recalculate on any change | -| `manual` | Batch operations, performance-critical workflows, formula debugging | -| `semi-automatic` | Workbooks with large data tables (Excel `xlCalculationSemiautomatic`) | - -**Returns:** -```json -{ - "success": true, - "previousMode": "automatic", - "newMode": "manual", - "message": "Calculation mode set to manual. Call calculate action when ready to recalculate.", - "suggestedNextActions": [ - "Perform your write operations (values, formulas, data)", - "calculation_mode(action: 'calculate') when ready to recalculate" - ] -} -``` - -**Important:** When session closes, mode should be restored to original state (safety net). - ---- - -#### 3. `calculate` - Trigger Calculation - -**Purpose:** Explicitly recalculate when in manual mode. - -**Parameters:** - -| Parameter | Type | Required | Default | Description | -|-----------|------|----------|---------|-------------| -| `scope` | string | No | `workbook` | `workbook`, `sheet`, or `range` | -| `sheetName` | string | No* | - | Required when scope = `sheet` or `range` | -| `rangeAddress` | string | No* | - | Required when scope = `range` | - -**Scope Details:** - -| Scope | Excel COM Method | Use Case | -|-------|------------------|----------| -| `workbook` | `Application.Calculate()` | After batch operations, general recalc | -| `sheet` | `Worksheet.Calculate()` | Targeted recalc, avoid touching other sheets | -| `range` | `Range.Calculate()` | Formula debugging, step-through analysis | - -**Returns:** -```json -{ - "success": true, - "scope": "sheet", - "sheetName": "Calculations", - "calculationState": "done", - "message": "Calculation complete for sheet 'Calculations'" -} -``` - ---- - -### Core Interface (ICalculationCommands) - -```csharp -namespace Sbroenne.ExcelMcp.Core.Commands.Calculation; - -public interface ICalculationCommands -{ - /// - /// Gets the current calculation mode and state - /// - CalculationModeResult GetMode(IExcelBatch batch); - - /// - /// Sets the calculation mode (automatic, manual, semi-automatic) - /// - OperationResult SetMode(IExcelBatch batch, CalculationMode mode); - - /// - /// Triggers calculation for the specified scope - /// - OperationResult Calculate(IExcelBatch batch, CalculationScope scope, string? sheetName = null, string? rangeAddress = null); -} - -public enum CalculationMode -{ - Automatic = -4105, // xlCalculationAutomatic - Manual = -4135, // xlCalculationManual - SemiAutomatic = 2 // xlCalculationSemiautomatic -} - -public enum CalculationScope -{ - Workbook, // Application.Calculate() - Sheet, // Worksheet.Calculate() - Range // Range.Calculate() -} - -public class CalculationModeResult : OperationResult -{ - public string Mode { get; set; } = string.Empty; - public int ModeValue { get; set; } - public string CalculationState { get; set; } = string.Empty; - public int CalculationStateValue { get; set; } - public bool IsPending { get; set; } -} -``` - ---- - -## CLI Commands - -### Command Structure - -```powershell -excelcli calculation [options] -``` - -### Actions - -```powershell -# Get current mode -excelcli calculation get-mode --session 1 -excelcli calculation get-mode --file "C:\Reports\Sales.xlsx" - -# Set mode -excelcli calculation set-mode --session 1 --mode manual -excelcli calculation set-mode --session 1 --mode automatic -excelcli calculation set-mode --session 1 --mode semi-automatic - -# Trigger calculation -excelcli calculation calculate --session 1 -excelcli calculation calculate --session 1 --scope sheet --sheet "Calculations" -excelcli calculation calculate --session 1 --scope range --sheet "Data" --range "E2:E100" -``` - ---- - -## LLM Workflow Examples - -### Example 1: High-Throughput Batch (Performance Optimization) - -**Scenario:** LLM needs to populate 500 cells with data and formulas. - -**Before (Current - Internal Toggling):** -``` -# 500 operations = 500 internal mode toggles + 500 recalculations -excelcli range set-values A1 ... # Toggle → Write → Toggle → Recalc -excelcli range set-values A2 ... # Toggle → Write → Toggle → Recalc -... × 500 -# Total: ~500 recalculations during operation -``` - -**After (With Calculation API):** -```powershell -# Step 1: Disable recalculation -excelcli calculation set-mode --session 1 --mode manual - -# Step 2: Batch operations (NO recalculations, internal toggle skipped) -excelcli range set-values --session 1 --sheet Data --range A1 --values '[["Value1"]]' -excelcli range set-values --session 1 --sheet Data --range A2 --values '[["Value2"]]' -... × 500 - -# Step 3: Single recalculation at the end -excelcli calculation calculate --session 1 -excelcli calculation set-mode --session 1 --mode automatic -# Total: 1 recalculation -``` - -**Expected Improvement:** 10-50% faster depending on formula complexity. - ---- - -### Example 2: Formula Debugging (Step-Through) - -**Scenario:** User reports formula `=INDEX(MATCH(...))` returns wrong value. LLM debugs. - -```powershell -# Step 1: Enter debug mode -excelcli calculation set-mode --session 1 --mode manual - -# Step 2: Check current formula -excelcli range get-formulas --session 1 --sheet Lookup --range E5 -# Returns: =INDEX(Products!B:B,MATCH(D5,Products!A:A,0)) - -# Step 3: Check lookup value -excelcli range get-values --session 1 --sheet Lookup --range D5 -# Returns: "Widget-A" - -# Step 4: Check if lookup value exists in source -excelcli range get-values --session 1 --sheet Products --range A1:A100 -# LLM scans: "Widget-A" is at row 15 - -# Step 5: Manually set D5 to a known good value -excelcli range set-values --session 1 --sheet Lookup --range D5 --values '[["Widget-B"]]' - -# Step 6: Recalculate JUST that cell -excelcli calculation calculate --session 1 --scope range --sheet Lookup --range E5 - -# Step 7: Check result -excelcli range get-values --session 1 --sheet Lookup --range E5 -# LLM: "Now it returns the correct value. The issue was the original D5 had trailing whitespace." - -# Step 8: Restore automatic mode -excelcli calculation set-mode --session 1 --mode automatic -``` - ---- - -### Example 3: Data Model Workbook (Timeout Prevention) - -**Scenario:** Workbook has Power Pivot Data Model. Writing values triggers expensive DAX recalculation. - -```powershell -# Problem: Without manual mode, this times out -excelcli range set-values --session 1 --sheet Input --range A2 --values '[[1000000]]' -# COM timeout: DAX measures recalculating across 5M rows - -# Solution: Batch inputs, then recalc -excelcli calculation set-mode --session 1 --mode manual - -excelcli range set-values --session 1 --sheet Input --range A2 --values '[[1000000]]' -excelcli range set-values --session 1 --sheet Input --range B2 --values '[["East"]]' -excelcli range set-values --session 1 --sheet Input --range C2 --values '[["2025-Q1"]]' - -# Now recalculate (user expects this to take time) -excelcli calculation calculate --session 1 -# Output: { "success": true, "message": "Calculation complete", "duration": "12.3s" } - -excelcli calculation set-mode --session 1 --mode automatic -``` - ---- - -### Example 4: Preview Formulas Before Committing - -**Scenario:** LLM builds complex formula, wants to verify syntax before recalculation. - -```powershell -excelcli calculation set-mode --session 1 --mode manual - -# Write formula (no immediate recalc) -excelcli range set-formulas --session 1 --sheet Analysis --range F2 \ - --formulas '[["=SUMPRODUCT((Region=\"East\")*(Year=2025)*Sales)"]]' - -# Read it back to verify -excelcli range get-formulas --session 1 --sheet Analysis --range F2 -# Returns: "=SUMPRODUCT((Region=\"East\")*(Year=2025)*Sales)" -# LLM: "Formula syntax looks correct." - -# Now calculate -excelcli calculation calculate --session 1 --scope range --sheet Analysis --range F2 - -# Check result -excelcli range get-values --session 1 --sheet Analysis --range F2 -# Returns: 1250000 - -excelcli calculation set-mode --session 1 --mode automatic -``` - ---- - -## Implementation Notes - -### Session Cleanup - -When a session closes (via `session close` or daemon shutdown), the calculation mode MUST be restored to its original value: - -```csharp -// In ExcelSession.Close() or Dispose() -if (_originalCalculationMode != null) -{ - try - { - _app.Calculation = _originalCalculationMode.Value; - } - catch - { - // Best effort - workbook closing anyway - } -} -``` - -### Integration with Existing Write Operations - -When calculation mode is already manual (set by user via this API), internal toggles should be skipped: - -```csharp -// In RangeCommands.SetValues, etc. -int currentMode = ctx.App.Calculation; -bool needsToggle = currentMode != -4135; // Only toggle if not already manual - -if (needsToggle) -{ - ctx.App.Calculation = -4135; -} - -try -{ - // ... write operation ... -} -finally -{ - if (needsToggle) - { - ctx.App.Calculation = currentMode; - } -} -``` - -### Response Enrichment - -When calculation mode is manual, include `calculationPending: true` in write operation responses: - -```json -{ - "success": true, - "action": "set-values", - "message": "Values written to Sheet1!A1:D10", - "calculationPending": true, - "hint": "Call 'calculate' action to recalculate formulas" -} -``` - ---- - -## MCP Tool Schema - -### Tool Definition - -```csharp -[McpServerToolType] -public static class ExcelCalculationTool -{ - /// - /// Control when Excel recalculates formulas (automatic vs manual mode). - /// - /// ACTIONS: - /// - get-mode: Query current calculation mode and state - /// - set-mode: Switch between automatic, manual, or semi-automatic - /// - calculate: Trigger recalculation (required when in manual mode) - /// - /// CALCULATION MODES: - /// - automatic: Recalculates on any change (default, standard behavior) - /// - manual: Only recalculates when you call 'calculate' action - /// - semi-automatic: Auto except data tables (for recalc-intensive workbooks) - /// - /// WHEN TO USE MANUAL MODE: - /// - Batch operations: Set manual → many writes → calculate once - /// - Data Model workbooks: Prevent DAX recalc timeouts - /// - Formula debugging: Change input → calculate → check output → repeat - /// - /// IMPORTANT: Mode is restored to original when session closes. - /// - [McpServerTool(Name = "calculation_mode")] - public static async Task ExecuteAsync( - [Description("Action: get-mode, set-mode, calculate")] - CalculationModeAction action, - - [Description("Session ID (required)")] - int session, - - [Description("Calculation mode for set-mode: automatic, manual, semi-automatic")] - string? mode = null, - - [Description("Calculation scope for calculate: workbook (default), sheet, range")] - string? scope = null, - - [Description("Sheet name (required when scope=sheet or scope=range)")] - string? sheetName = null, - - [Description("Range address (required when scope=range)")] - string? rangeAddress = null - ) - { - // Implementation... - } -} -``` - ---- - -## Testing Strategy - -### Test Categories - -1. **Mode Toggle Tests** - - Get mode returns correct values - - Set mode changes Application.Calculation - - Set mode preserves original for session cleanup - -2. **Calculate Scope Tests** - - Workbook scope calculates all sheets - - Sheet scope calculates only target sheet - - Range scope calculates only target range - -3. **Integration Tests** - - Manual mode + batch writes + single calculate - - Manual mode persists across multiple operations - - Session close restores original mode - -4. **Performance Tests** (OnDemand) - - Measure time: 100 writes with internal toggling vs manual mode - - Document actual improvement percentage - -5. **Data Model Tests** - - Verify no timeout when writing to cells with DAX dependencies in manual mode - - Verify DAX recalculates correctly after explicit calculate call - -### Test Traits - -```csharp -[Trait("Feature", "Calculation")] -[Trait("Category", "Integration")] -[Trait("RequiresExcel", "true")] -``` - ---- - -## Migration Path - -### Phase 1: Core Implementation -- Add `ICalculationCommands` interface -- Implement `CalculationCommands` class -- Add unit tests - -### Phase 2: MCP Server -- Add `calculation_mode` tool -- Add `CalculationModeAction` enum -- Update tool count in docs - -### Phase 3: CLI -- Add `calculation` command group -- Implement `get-mode`, `set-mode`, `calculate` subcommands -- Update CLI documentation - -### Phase 4: Optimization -- Modify existing write operations to skip internal toggle when already manual -- Add `calculationPending` to write operation responses -- Update skill files with calculation mode guidance - ---- - -## Acceptance Criteria - -### Functional -- [ ] `get-mode` returns current mode and calculation state -- [ ] `set-mode` changes calculation mode -- [ ] `calculate` triggers recalculation at specified scope -- [ ] Session close restores original calculation mode -- [ ] Write operations skip internal toggle when already in manual mode - -### Performance -- [ ] Batch of 100 writes is measurably faster with manual mode -- [ ] No COM timeouts on Data Model workbooks in manual mode - -### Documentation -- [ ] MCP tool has comprehensive XML documentation -- [ ] CLI has help text for all commands -- [ ] Skill files updated with calculation mode workflows -- [ ] FEATURES.md updated with new tool/actions - -### Testing -- [ ] All calculate scopes tested (workbook, sheet, range) -- [ ] Mode persistence across operations verified -- [ ] Session cleanup verified -- [ ] Integration with existing write operations verified - ---- - -## Open Questions - -1. **Should we track original mode per-session or globally?** - - Recommendation: Per-session. Multiple sessions might want different modes. - -2. **Should calculate action report duration?** - - Recommendation: Yes. Useful for LLMs to understand workbook complexity. - -3. **Should we add a `force-calculate` that works even in automatic mode?** - - Recommendation: No. `Application.Calculate()` already works in automatic mode. - -4. **Should response include dirty cell count when in manual mode?** - - Recommendation: Nice to have. Low priority. `calculationPending: true` is sufficient. - ---- - -## Summary - -The Calculation Mode API transforms calculation control from an invisible internal optimization to an explicit, user-controllable feature. This enables: - -| Capability | Current | With This API | -|------------|---------|---------------| -| Batch performance | 50 writes = 50 recalcs | 50 writes = 1 recalc | -| Data Model safety | Internal toggle (helps) | Explicit control (better) | -| Formula debugging | Not possible | Step-through analysis | -| Timing predictability | Surprises possible | Fully controlled | - -**Estimated Effort:** Medium (3-5 days) -- Core: 1 day -- MCP Server: 0.5 day -- CLI: 0.5 day -- Tests: 1-2 days -- Documentation: 0.5 day - ---- - -## When NOT to Use Manual Mode - -Manual mode adds complexity. Use automatic mode (default) when: - -| Scenario | Why Automatic is Better | -|----------|------------------------| -| **Single-cell reads/writes** | Overhead of mode toggle > recalc cost | -| **Simple workbooks** | No complex formulas to recalculate anyway | -| **When you need immediate feedback** | Reading calculated values right after write | -| **Unfamiliar workbooks** | Don't know if formula dependencies exist | -| **Interactive user sessions** | User expects Excel to update in real-time | - -**Rule of Thumb:** If you're doing < 10 write operations, don't bother with manual mode. - ---- - -## Integration with Other Tools - -### Power Query (`powerquery`) - -Manual mode is **highly recommended** when: -- Importing multiple queries in sequence -- Refreshing queries that feed into Data Model -- Building query chains (one query references another) - -```powershell -excelcli calculation set-mode --session 1 --mode manual -excelcli powerquery import --session 1 --name "Sales" --formula "..." -excelcli powerquery import --session 1 --name "Products" --formula "..." -excelcli powerquery import --session 1 --name "Combined" --formula "..." # References Sales, Products -excelcli calculation calculate --session 1 -excelcli calculation set-mode --session 1 --mode automatic -``` - -### Data Model (`datamodel`) - -**Critical for timeout prevention:** -- Writing to cells that feed DAX measures -- Adding multiple measures in sequence -- Refreshing Data Model connections - -```powershell -excelcli calculation set-mode --session 1 --mode manual -excelcli datamodel add-measure --session 1 --table Sales --name "Total Revenue" --formula "SUM(Sales[Amount])" -excelcli datamodel add-measure --session 1 --table Sales --name "YoY Growth" --formula "..." -excelcli range set-values --session 1 --sheet Input --range A2 --values '[[1000000]]' # Feeds DAX -excelcli calculation calculate --session 1 -excelcli calculation set-mode --session 1 --mode automatic -``` - -### PivotTables (`pivottable`) - -Consider manual mode when: -- Creating multiple PivotTables from same source -- Configuring PivotTable then adding slicers -- Batch field configuration - ---- - -## Documentation Update Checklist - -When this feature is implemented, **ALL** these files require updates: - -### Tool/Operation Counts (22 → 23 tools, 211 → 214 operations) - -| File | Location | Change | -|------|----------|--------| -| `README.md` | Line ~21, ~84, ~100 | Update tool/operation counts | -| `src/ExcelMcp.McpServer/README.md` | Lines ~18, ~56, ~72 | Update counts | -| `src/ExcelMcp.CLI/README.md` | Lines ~9, ~102 | Update counts (14 → 15 categories) | -| `vscode-extension/README.md` | Line ~19 | Update counts | -| `gh-pages/index.md` | Feature reference link | Update counts | -| `gh-pages/features.md` | Add new section | Add `calculation_mode` tool | -| `gh-pages/404.md` | Line ~20 | Update counts | -| `mcpb/manifest.json` | `long_description` | Update counts | -| `src/ExcelMcp.McpServer/Program.cs` | Line ~280 | Update server description | -| `FEATURES.md` | Add new section | Full tool documentation | -| `tests/.../McpServerSmokeTests.cs` | Line ~179 | Update expected tool count (22 → 23) | - -### Skills (LLM Guidance) - -| File | Change | -|------|--------| -| `skills/shared/workflows.md` | Add "Batch Operations with Calculation Mode" workflow | -| `skills/shared/behavioral-rules.md` | Add rule: "Use manual mode for batch operations" | -| `skills/excel-mcp/SKILL.md` | Add `calculation_mode` to tool reference | -| `skills/excel-cli/SKILL.md` | Add `calculation` command reference | -| **NEW:** `skills/shared/excel_calculation.md` | Dedicated calculation mode guidance | - -### MCP Prompts - -| File | Change | -|------|--------| -| `src/ExcelMcp.McpServer/Prompts/Content/datamodel.md` | Add: "Use manual mode when writing to cells with DAX dependencies" | -| `src/ExcelMcp.McpServer/Prompts/Content/powerquery.md` | Add: "Consider manual mode during batch query operations" | - -### Code Changes - -| File | Change | -|------|--------| -| `src/ExcelMcp.Core/ToolActions.cs` | Add `CalculationModeAction` enum | -| `src/ExcelMcp.Core/ActionExtensions.cs` | Add `ToActionString()` for new enum | -| Write operation commands | Skip internal toggle when already manual | -| Write operation results | Add `calculationPending: true` when in manual mode | - ---- - -## LLM Testing with pytest-aitest - -This feature should be validated using [pytest-aitest](https://github.com/sbroenne/pytest-aitest), which tests whether LLMs can correctly understand and use the new tools. - -### Why pytest-aitest? - -- **Tests the AI interface, not just code** - Validates tool descriptions, not just implementations -- **AI-powered reports** - Tells you *what to fix*, not just *what failed* -- **Native pytest** - Integrates with existing Python test infrastructure -- **Supports both MCP and CLI** - Test both interfaces with same framework - -### Proposed Test Cases - -```python -# tests/ExcelMcp.AITests/test_calculation_mode.py -import pytest -from pytest_aitest import Agent, CLIServer, Provider, Skill - -@pytest.fixture -def excel_cli_server(): - return CLIServer( - name="excel-cli", - command="excelcli", - tool_prefix="excel", - shell="powershell", - ) - -@pytest.fixture -def excel_skill(): - return Skill.from_path("skills/excel-cli") - - -class TestCalculationModeDiscovery: - """Test that LLMs discover and understand calculation mode.""" - - @pytest.mark.asyncio - async def test_llm_uses_manual_mode_for_batch(self, aitest_run, excel_cli_server, excel_skill): - """LLM should use manual mode when doing many writes.""" - agent = Agent( - name="batch-test", - provider=Provider(model="azure/gpt-5-mini"), - cli_servers=[excel_cli_server], - skill=excel_skill, - max_turns=15, - ) - - result = await aitest_run( - agent, - "Create a workbook, add 20 rows of sample data efficiently, then close it." - ) - - assert result.success - assert result.tool_was_called("excel_execute") - # Tool output should show set-mode manual was used - - @pytest.mark.asyncio - async def test_llm_restores_automatic_mode(self, aitest_run, excel_cli_server, excel_skill): - """LLM should restore automatic mode after batch operations.""" - agent = Agent( - name="restore-test", - provider=Provider(model="azure/gpt-5-mini"), - cli_servers=[excel_cli_server], - skill=excel_skill, - max_turns=15, - ) - - result = await aitest_run( - agent, - "Create a workbook with sample data using efficient batching, " - "then verify the calculation mode is back to automatic before closing." - ) - - assert result.success - - -class TestCalculationModeDebugging: - """Test formula debugging workflow with calculation mode.""" - - @pytest.mark.asyncio - async def test_step_through_debugging(self, aitest_run, excel_cli_server, excel_skill, llm_assert): - """LLM can use manual mode for step-through formula debugging.""" - agent = Agent( - name="debug-test", - provider=Provider(model="azure/gpt-5-mini"), - cli_servers=[excel_cli_server], - skill=excel_skill, - max_turns=20, - ) - - result = await aitest_run( - agent, - "Create a workbook with A1=10, A2=20, and A3=SUM(A1:A2). " - "Use manual calculation mode to verify the formula. " - "Change A1 to 100, recalculate just A3, and report the new value." - ) - - assert result.success - assert llm_assert(result.final_response, "mentions the calculated value 120") -``` - -### Test Categories - -| Category | What It Tests | -|----------|---------------| -| **Discovery** | LLM finds and uses `calculation_mode` appropriately | -| **Batch Optimization** | LLM uses manual mode for bulk operations | -| **Debugging Workflow** | LLM uses range-scope calculation for step-through | -| **Safety** | LLM restores automatic mode after batch | -| **Data Model** | LLM uses manual mode with DAX-heavy workbooks | - -### Report Insights Expected - -With pytest-aitest's AI-powered reports, we expect insights like: - -``` -🎯 RECOMMENDATION -Tool description is clear - 100% of models correctly identified when to use manual mode. - -🔧 MCP TOOL FEEDBACK -⚠️ calculation_mode(action='calculate', scope='range') - Low usage (2 calls across 15 tests) - Suggested: Add example to tool description showing range-scope debugging workflow -``` diff --git a/specs/CHART-API-SPECIFICATION.md b/specs/CHART-API-SPECIFICATION.md deleted file mode 100644 index 752795fd..00000000 --- a/specs/CHART-API-SPECIFICATION.md +++ /dev/null @@ -1,921 +0,0 @@ -# Excel Chart API Specification - -> **Comprehensive specification for Excel chart operations - creating and managing Regular Charts and PivotCharts** - -## Implementation Status - -**Phase 1 (MVP): 🚧 IN PROGRESS** (As of November 22, 2025) -- 🚧 Core interface and strategy pattern design -- 🚧 Regular Chart and PivotChart lifecycle operations -- 🚧 MCP Server integration planning -- ⏸️ CLI commands (after core implementation) -- ⏸️ Integration tests (after core implementation) - -**Target: 20-25 core operations covering 90% of chart automation use cases** - ---- - -## Executive Summary - -This specification defines a **Chart API** for ExcelMcp that provides complete chart lifecycle management, data source configuration, and appearance customization through Excel COM automation. The API handles **two fundamentally different chart types**: - -1. **Regular Charts** - Static charts created from Excel ranges/tables -2. **PivotCharts** - Dynamic interactive charts linked to PivotTables - -### Key Design Decisions - -1. **COM-Backed Only** - Every operation uses native Excel COM Chart objects -2. **Two-Type Strategy Pattern** - Regular vs PivotChart behavior differences handled transparently -3. **Unified API** - Same method signatures for both chart types (strategy handles implementation) -4. **Complete ChartType Enum** - All 70+ Excel chart types exposed (grouped by category) -5. **Positioning Required** - Both Regular and PivotCharts require left/top/width/height coordinates - -### Goals - -1. **Complete Lifecycle** - Create, read, move, delete charts of both types -2. **Data Source Management** - Set ranges, add/remove series (Regular), sync with PivotTable (PivotCharts) -3. **Appearance Control** - Chart types, titles, legends, styles -4. **LLM-Friendly** - Clear error messages guide workflow when operations differ between types -5. **90% Coverage** - Core operations satisfy most automation scenarios - ---- - -## Excel Chart Architecture - -### What Are Excel Charts? - -Excel charts are **visual representations of data** that provide: -- Multiple chart types (column, line, pie, scatter, etc.) -- Embedded positioning on worksheets (left, top, width, height) -- Data series from ranges or PivotTable fields -- Titles, legends, axes, and styling -- Export and presentation capabilities - -### The Two Chart Types (Behavioral Difference) - -#### Regular Charts -- **Data Source**: Excel ranges or tables -- **Behavior**: Static - updates only when source range updates -- **Series Management**: Explicit via SeriesCollection -- **Creation**: `Shapes.AddChart()` or `ChartObjects.Add()` -- **Use Cases**: Reports, dashboards, static analysis - -#### PivotCharts -- **Data Source**: PivotTable/PivotCache -- **Behavior**: Dynamic - updates automatically when PivotTable changes -- **Series Management**: Automatic sync with PivotTable value fields -- **Creation**: `PivotCache.CreatePivotChart()` or `Shapes.AddChart2()` + link -- **Use Cases**: Interactive analysis, drill-down reports, OLAP cubes - -### Excel COM Object Model - -#### Core Objects -```csharp -// Worksheet-level chart access -dynamic shapes = worksheet.Shapes; -dynamic chartObjects = worksheet.ChartObjects; - -// Regular Chart creation (Modern API - Excel 2010+) -dynamic shape = shapes.AddChart( - XlChartType: 51, // xlColumnClustered - Left: 100, - Top: 100, - Width: 400, - Height: 300 -); -dynamic chart = shape.Chart; - -// PivotChart creation (from PivotTable) -dynamic pivotCache = pivotTable.PivotCache(); -dynamic pivotChart = pivotCache.CreatePivotChart( - Destination: worksheet.Range["H1"] // Top-left position -); - -// Chart object hierarchy -dynamic chart = chartObject.Chart; // OR shape.Chart -dynamic seriesCollection = chart.SeriesCollection(); -dynamic series = seriesCollection.Item(1); -``` - -#### Chart vs ChartObject -- **ChartObject** - Wrapper providing positioning (Left, Top, Width, Height) -- **Chart** - The actual chart with data, type, titles, legends -- Both Regular and PivotCharts use this dual-object pattern - ---- - -## Proposed Chart API Design - -### Design Principles - -1. **COM-Backed Only**: Every method uses native Excel COM Chart operations -2. **Strategy Pattern**: `IChartStrategy` with `RegularChartStrategy` and `PivotChartStrategy` -3. **Unified API**: Same method signatures - strategy handles implementation differences -4. **Complete Enum**: All 70+ chart types exposed, grouped by category for readability -5. **Error Guidance**: Clear messages guide LLMs when operations differ between types - -### Phase 1: Core Operations (MVP) - -```csharp -public interface IChartCommands -{ - // === LIFECYCLE OPERATIONS === - - /// - /// Lists all charts in workbook (Regular and PivotCharts) - /// - /// Excel batch session - /// List of charts with names, types, sheets, positions, data sources - ChartListResult List(IExcelBatch batch); - - /// - /// Gets complete chart configuration - /// - /// Excel batch session - /// Name of the chart (or shape name) - /// Chart type, data source, series info, position, styling - ChartInfoResult Read(IExcelBatch batch, string chartName); - - /// - /// Creates a Regular Chart from an Excel range - /// - /// Excel batch session - /// Worksheet name for chart placement - /// Source data range (e.g., "A1:D10") - /// Chart type from ChartType enum - /// Left position in points - /// Top position in points - /// Width in points (default: 400) - /// Height in points (default: 300) - /// Optional name for the chart - /// Created chart name and configuration - ChartCreateResult CreateFromRange(IExcelBatch batch, - string sheetName, string sourceRange, - ChartType chartType, - double left, double top, - double width = 400, double height = 300, - string? chartName = null); - - /// - /// Creates a PivotChart from an existing PivotTable - /// - /// Excel batch session - /// Name of the PivotTable - /// Worksheet name for chart placement - /// Chart type from ChartType enum - /// Left position in points - /// Top position in points - /// Width in points (default: 400) - /// Height in points (default: 300) - /// Optional name for the chart - /// Created PivotChart name and linked PivotTable - ChartCreateResult CreateFromPivotTable(IExcelBatch batch, - string pivotTableName, string sheetName, - ChartType chartType, - double left, double top, - double width = 400, double height = 300, - string? chartName = null); - - /// - /// Deletes a chart (Regular or PivotChart) - /// - /// Excel batch session - /// Name of the chart to delete - /// Operation result - OperationResult Delete(IExcelBatch batch, string chartName); - - /// - /// Moves/resizes a chart - /// - /// Excel batch session - /// Name of the chart - /// New left position in points (null = no change) - /// New top position in points (null = no change) - /// New width in points (null = no change) - /// New height in points (null = no change) - /// Operation result with new position - OperationResult Move(IExcelBatch batch, string chartName, - double? left = null, double? top = null, - double? width = null, double? height = null); - - // === DATA SOURCE OPERATIONS === - - /// - /// Sets data source range for Regular Charts - /// PivotCharts: Returns error guiding to pivottable - /// - /// Excel batch session - /// Name of the chart - /// New source range (e.g., "Sheet1!A1:D10") - /// Operation result - OperationResult SetSourceRange(IExcelBatch batch, string chartName, string sourceRange); - - /// - /// Adds a data series to Regular Charts - /// PivotCharts: Returns error guiding to pivottable(action: 'add-value-field') - /// - /// Excel batch session - /// Name of the chart - /// Name for the series - /// Range containing Y values (e.g., "Sheet1!B2:B10") - /// Optional range for X values/categories - /// Series information - ChartSeriesResult AddSeries(IExcelBatch batch, string chartName, - string seriesName, string valuesRange, string? categoryRange = null); - - /// - /// Removes a data series from Regular Charts - /// PivotCharts: Returns error guiding to pivottable(action: 'remove-field') - /// - /// Excel batch session - /// Name of the chart - /// 1-based index of series to remove - /// Operation result - OperationResult RemoveSeries(IExcelBatch batch, string chartName, int seriesIndex); - - // === APPEARANCE OPERATIONS === - - /// - /// Changes chart type (works for both Regular and PivotCharts) - /// - /// Excel batch session - /// Name of the chart - /// New chart type from ChartType enum - /// Operation result - OperationResult SetChartType(IExcelBatch batch, string chartName, ChartType chartType); - - /// - /// Sets chart title - /// - /// Excel batch session - /// Name of the chart - /// Chart title text (empty to hide title) - /// Operation result - OperationResult SetTitle(IExcelBatch batch, string chartName, string title); - - /// - /// Sets axis title - /// - /// Excel batch session - /// Name of the chart - /// Axis type (Primary, Secondary, Category, Value) - /// Axis title text - /// Operation result - OperationResult SetAxisTitle(IExcelBatch batch, string chartName, - ChartAxisType axis, string title); - - /// - /// Shows or hides chart legend - /// - /// Excel batch session - /// Name of the chart - /// True to show legend, false to hide - /// Legend position (optional) - /// Operation result - OperationResult ShowLegend(IExcelBatch batch, string chartName, - bool visible, LegendPosition? position = null); - - /// - /// Applies a chart style - /// - /// Excel batch session - /// Name of the chart - /// Style number (1-48) - /// Operation result - OperationResult SetStyle(IExcelBatch batch, string chartName, int styleId); -} - -// === SUPPORTING TYPES === - -/// -/// Excel chart types - All 70+ values grouped by category -/// Excel COM: XlChartType enumeration -/// Reference: https://learn.microsoft.com/office/vba/api/excel.xlcharttype -/// -public enum ChartType -{ - // === COLUMN CHARTS === - ColumnClustered = 51, // xlColumnClustered - ColumnStacked = 52, // xlColumnStacked - ColumnStacked100 = 53, // xlColumnStacked100 - Column3DClustered = 54, // xl3DColumnClustered - Column3DStacked = 55, // xl3DColumnStacked - Column3DStacked100 = 56, // xl3DColumnStacked100 - Column3D = -4100, // xl3DColumn - - // === BAR CHARTS === - BarClustered = 57, // xlBarClustered - BarStacked = 58, // xlBarStacked - BarStacked100 = 59, // xlBarStacked100 - Bar3DClustered = 60, // xl3DBarClustered - Bar3DStacked = 61, // xl3DBarStacked - Bar3DStacked100 = 62, // xl3DBarStacked100 - - // === LINE CHARTS === - Line = 4, // xlLine - LineStacked = 63, // xlLineStacked - LineStacked100 = 64, // xlLineStacked100 - LineMarkers = 65, // xlLineMarkers - LineMarkersStacked = 66, // xlLineMarkersStacked - LineMarkersStacked100 = 67, // xlLineMarkersStacked100 - Line3D = -4101, // xl3DLine - - // === PIE CHARTS === - Pie = 5, // xlPie - Pie3D = -4102, // xl3DPie - PieOfPie = 68, // xlPieOfPie - PieExploded = 69, // xlPieExploded - PieExploded3D = 70, // xl3DPieExploded - BarOfPie = 71, // xlBarOfPie - - // === SCATTER (XY) CHARTS === - XYScatter = -4169, // xlXYScatter - XYScatterSmooth = 72, // xlXYScatterSmooth - XYScatterSmoothNoMarkers = 73, // xlXYScatterSmoothNoMarkers - XYScatterLines = 74, // xlXYScatterLines - XYScatterLinesNoMarkers = 75, // xlXYScatterLinesNoMarkers - - // === AREA CHARTS === - Area = 1, // xlArea - AreaStacked = 76, // xlAreaStacked - AreaStacked100 = 77, // xlAreaStacked100 - Area3D = -4098, // xl3DArea - Area3DStacked = 78, // xl3DAreaStacked - Area3DStacked100 = 79, // xl3DAreaStacked100 - - // === DOUGHNUT CHARTS === - Doughnut = -4120, // xlDoughnut - DoughnutExploded = 80, // xlDoughnutExploded - - // === RADAR CHARTS === - Radar = -4151, // xlRadar - RadarMarkers = 81, // xlRadarMarkers - RadarFilled = 82, // xlRadarFilled - - // === SURFACE CHARTS === - Surface = 83, // xlSurface - SurfaceWireframe = 84, // xlSurfaceWireframe - SurfaceTopView = 85, // xlSurfaceTopView - SurfaceTopViewWireframe = 86, // xlSurfaceTopViewWireframe - - // === BUBBLE CHARTS === - Bubble = 15, // xlBubble - Bubble3DEffect = 87, // xlBubble3DEffect - - // === STOCK CHARTS === - StockHLC = 88, // xlStockHLC (High-Low-Close) - StockOHLC = 89, // xlStockOHLC (Open-High-Low-Close) - StockVHLC = 90, // xlStockVHLC (Volume-High-Low-Close) - StockVOHLC = 91, // xlStockVOHLC (Volume-Open-High-Low-Close) - - // === CYLINDER CHARTS === - CylinderBarClustered = 95, // xlCylinderBarClustered - CylinderBarStacked = 96, // xlCylinderBarStacked - CylinderBarStacked100 = 97, // xlCylinderBarStacked100 - CylinderCol = 98, // xlCylinderCol - CylinderColClustered = 92, // xlCylinderColClustered - CylinderColStacked = 93, // xlCylinderColStacked - CylinderColStacked100 = 94, // xlCylinderColStacked100 - - // === CONE CHARTS === - ConeBarClustered = 102, // xlConeBarClustered - ConeBarStacked = 103, // xlConeBarStacked - ConeBarStacked100 = 104, // xlConeBarStacked100 - ConeCol = 105, // xlConeCol - ConeColClustered = 99, // xlConeColClustered - ConeColStacked = 100, // xlConeColStacked - ConeColStacked100 = 101, // xlConeColStacked100 - - // === PYRAMID CHARTS === - PyramidBarClustered = 109, // xlPyramidBarClustered - PyramidBarStacked = 110, // xlPyramidBarStacked - PyramidBarStacked100 = 111, // xlPyramidBarStacked100 - PyramidCol = 112, // xlPyramidCol - PyramidColClustered = 106, // xlPyramidColClustered - PyramidColStacked = 107, // xlPyramidColStacked - PyramidColStacked100 = 108, // xlPyramidColStacked100 - - // === MODERN CHARTS (Excel 2016+) === - Treemap = 117, // xlTreemap - Sunburst = 116, // xlSunburst - Histogram = 118, // xlHistogram - Pareto = 122, // xlPareto - BoxWhisker = 121, // xlBoxWhisker - Waterfall = 119, // xlWaterfall - Funnel = 123, // xlFunnel - - // === COMBO CHARTS === - ColumnLineCombo = 120, // xlColumnLineCombo (approximation) - RegionMap = 140 // xlRegionMap (Excel 365) -} - -public enum ChartAxisType -{ - Primary, - Secondary, - Category, - Value -} - -public enum LegendPosition -{ - Bottom = -4107, // xlLegendPositionBottom - Corner = 2, // xlLegendPositionCorner - Custom = -4161, // xlLegendPositionCustom - Left = -4131, // xlLegendPositionLeft - Right = -4152, // xlLegendPositionRight - Top = -4160 // xlLegendPositionTop -} - -public class ChartListResult -{ - public List Charts { get; set; } = new(); - public string FilePath { get; set; } = string.Empty; -} - -public class ChartInfo -{ - public string Name { get; set; } = string.Empty; - public string SheetName { get; set; } = string.Empty; - public ChartType ChartType { get; set; } - public bool IsPivotChart { get; set; } - public string? LinkedPivotTable { get; set; } - public double Left { get; set; } - public double Top { get; set; } - public double Width { get; set; } - public double Height { get; set; } - public int SeriesCount { get; set; } -} - -public class ChartInfo -{ - public string Name { get; set; } = string.Empty; - public string SheetName { get; set; } = string.Empty; - public ChartType ChartType { get; set; } - public bool IsPivotChart { get; set; } - public string? LinkedPivotTable { get; set; } - public string? SourceRange { get; set; } - public double Left { get; set; } - public double Top { get; set; } - public double Width { get; set; } - public double Height { get; set; } - public string? Title { get; set; } - public bool HasLegend { get; set; } - public List Series { get; set; } = new(); -} - -public class SeriesInfo -{ - public string Name { get; set; } = string.Empty; - public string ValuesRange { get; set; } = string.Empty; - public string? CategoryRange { get; set; } -} - -public class ChartCreateResult : OperationResult -{ - public string ChartName { get; set; } = string.Empty; - public string SheetName { get; set; } = string.Empty; - public ChartType ChartType { get; set; } - public bool IsPivotChart { get; set; } - public string? LinkedPivotTable { get; set; } - public double Left { get; set; } - public double Top { get; set; } - public double Width { get; set; } - public double Height { get; set; } -} - -public class ChartSeriesResult : OperationResult -{ - public string SeriesName { get; set; } = string.Empty; - public string ValuesRange { get; set; } = string.Empty; - public string? CategoryRange { get; set; } - public int SeriesIndex { get; set; } -} -``` - ---- - -## Strategy Pattern Design - -### IChartStrategy Interface - -```csharp -public interface IChartStrategy -{ - /// - /// Determines if this strategy can handle the chart - /// - bool CanHandle(dynamic chart); - - /// - /// Gets chart information - /// - ChartInfo GetInfo(dynamic chart, string chartName); - - /// - /// Sets the data source (Regular: range, PivotChart: error) - /// - OperationResult SetSourceRange(dynamic chart, string sourceRange); - - /// - /// Adds a series (Regular: SeriesCollection, PivotChart: error) - /// - ChartSeriesResult AddSeries(dynamic chart, string seriesName, - string valuesRange, string? categoryRange); - - /// - /// Removes a series (Regular: SeriesCollection, PivotChart: error) - /// - OperationResult RemoveSeries(dynamic chart, int seriesIndex); -} -``` - -### RegularChartStrategy - -```csharp -public class RegularChartStrategy : IChartStrategy -{ - public bool CanHandle(dynamic chart) - { - // Regular charts: chart.PivotLayout is null or doesn't exist - try - { - var pivotLayout = chart.PivotLayout; - return pivotLayout == null; - } - catch - { - return true; // No PivotLayout = Regular chart - } - } - - public OperationResult SetSourceRange(dynamic chart, string sourceRange) - { - // Use chart.SetSourceData(range, plotBy) - chart.SetSourceData(sourceRange); - return new OperationResult { Success = true }; - } - - public ChartSeriesResult AddSeries(dynamic chart, string seriesName, - string valuesRange, string? categoryRange) - { - dynamic seriesCollection = chart.SeriesCollection(); - dynamic newSeries = seriesCollection.NewSeries(); - newSeries.Name = seriesName; - newSeries.Values = valuesRange; - if (categoryRange != null) - { - newSeries.XValues = categoryRange; - } - // ... return result - } -} -``` - -### PivotChartStrategy - -```csharp -public class PivotChartStrategy : IChartStrategy -{ - public bool CanHandle(dynamic chart) - { - // PivotCharts: chart.PivotLayout exists - try - { - var pivotLayout = chart.PivotLayout; - return pivotLayout != null; - } - catch - { - return false; - } - } - - public OperationResult SetSourceRange(dynamic chart, string sourceRange) - { - // PivotCharts can't change source - return helpful error - return new OperationResult - { - Success = false, - ErrorMessage = "Cannot set source range for PivotChart. " + - "PivotCharts automatically sync with their PivotTable data source. " + - "To modify data, use pivottable tool to update the linked PivotTable." - }; - } - - public ChartSeriesResult AddSeries(dynamic chart, string seriesName, - string valuesRange, string? categoryRange) - { - // PivotCharts auto-sync with PivotTable fields - return helpful error - string pivotTableName = chart.PivotLayout.PivotTable.Name; - return new ChartSeriesResult - { - Success = false, - ErrorMessage = $"Cannot add series directly to PivotChart. " + - $"PivotCharts automatically sync with PivotTable '{pivotTableName}' fields. " + - $"Use pivottable(action: 'add-value-field', pivotTableName: '{pivotTableName}', fieldName: '') " + - $"to add data series." - }; - } -} -``` - ---- - -## Excel COM Implementation Details - -### Chart Creation Patterns - -#### Regular Chart (Modern API - Excel 2010+) - -```csharp -// Using Shapes.AddChart (recommended) -dynamic shapes = worksheet.Shapes; -dynamic shape = shapes.AddChart( - XlChartType: 51, // xlColumnClustered - Left: 100, - Top: 100, - Width: 400, - Height: 300 -); -dynamic chart = shape.Chart; - -// Set data source -chart.SetSourceData(worksheet.Range["A1:D10"]); - -// Optional: Name the chart -shape.Name = "SalesChart"; -``` - -#### PivotChart Creation - -```csharp -// Method 1: From PivotCache -dynamic pivotTable = FindPivotTable(workbook, "SalesPivot"); -dynamic pivotCache = pivotTable.PivotCache(); - -// CreatePivotChart returns Shape object containing the chart -dynamic pivotChartShape = pivotCache.CreatePivotChart( - Destination: worksheet.Range["H1"] // Top-left corner -); - -// Access the chart -dynamic pivotChart = pivotChartShape.Chart; - -// Set chart type -pivotChart.ChartType = 51; // xlColumnClustered - -// Position/resize -pivotChartShape.Left = 500; -pivotChartShape.Top = 100; -pivotChartShape.Width = 400; -pivotChartShape.Height = 300; -``` - -### Chart Detection (Regular vs PivotChart) - -```csharp -public static bool IsPivotChart(dynamic chart) -{ - try - { - var pivotLayout = chart.PivotLayout; - return pivotLayout != null; - } - catch - { - return false; // No PivotLayout property = Regular chart - } -} -``` - -### Finding Charts - -```csharp -// Find by shape name -dynamic shapes = worksheet.Shapes; -for (int i = 1; i <= shapes.Count; i++) -{ - dynamic shape = shapes.Item(i); - if (shape.Type == 3) // msoChart = 3 - { - if (shape.Name == chartName) - { - return shape.Chart; - } - } -} - -// OR use ChartObjects collection (legacy but still works) -dynamic chartObjects = worksheet.ChartObjects; -for (int i = 1; i <= chartObjects.Count; i++) -{ - dynamic chartObject = chartObjects.Item(i); - if (chartObject.Name == chartName) - { - return chartObject.Chart; - } -} -``` - ---- - -## MCP Tool: chart - -### Actions (20-25 operations) - -```typescript -{ - "name": "chart", - "description": "Excel chart operations - create and manage Regular Charts and PivotCharts", - "parameters": { - "action": "enum", - "excelPath": "string", - "sessionId": "string", - "chartName": "string", - "sheetName": "string", - "sourceRange": "string", - "pivotTableName": "string", - "chartType": "enum", - "left": "double", - "top": "double", - "width": "double", - "height": "double", - "seriesName": "string", - "valuesRange": "string", - "categoryRange": "string", - "seriesIndex": "int", - "title": "string", - "axis": "enum", - "visible": "boolean", - "legendPosition": "enum", - "styleId": "int" - }, - "actions": [ - // Lifecycle (7 ops) - "list", // List all charts - "read", // Get chart details - "create-from-range", // Create Regular Chart - "create-from-pivottable", // Create PivotChart - "delete", // Delete chart - "move", // Move/resize chart - - // Data Source (3 ops) - "set-source-range", // Set data source (Regular only) - "add-series", // Add series (Regular only, PivotChart returns error) - "remove-series", // Remove series (Regular only, PivotChart returns error) - - // Appearance (5 ops) - "set-chart-type", // Change chart type - "set-title", // Set chart title - "set-axis-title", // Set axis title - "show-legend", // Show/hide legend - "set-style" // Apply style (1-48) - ] -} -``` - ---- - -## CLI Commands - -```powershell -# === LIFECYCLE === -excelcli chart list -excelcli chart read -excelcli chart create-from-range [width] [height] [name] -excelcli chart create-from-pivottable [width] [height] [name] -excelcli chart delete -excelcli chart move [left] [top] [width] [height] - -# === DATA SOURCE === -excelcli chart set-source-range -excelcli chart add-series [category-range] -excelcli chart remove-series - -# === APPEARANCE === -excelcli chart set-chart-type -excelcli chart set-title -excelcli chart set-axis-title <session-id> <chart-name> <axis> <title> -excelcli chart show-legend <session-id> <chart-name> <true|false> [position] -excelcli chart set-style <session-id> <chart-name> <style-id> -``` - ---- - -## Usage Examples - -### Creating Regular Chart - -```csharp -// Create column chart from range -var result = await chartCommands.CreateFromRange( - batch, - sheetName: "Data", - sourceRange: "A1:D10", - chartType: ChartType.ColumnClustered, - left: 100, - top: 100, - width: 400, - height: 300, - chartName: "SalesChart" -); - -// Customize appearance -await chartCommands.SetTitle(batch, "SalesChart", "Sales by Region"); -await chartCommands.SetAxisTitle(batch, "SalesChart", ChartAxisType.Value, "Revenue ($)"); -await chartCommands.ShowLegend(batch, "SalesChart", true, LegendPosition.Bottom); -``` - -### Creating PivotChart - -```csharp -// Create PivotChart from existing PivotTable -var result = await chartCommands.CreateFromPivotTable( - batch, - pivotTableName: "SalesPivot", - sheetName: "Dashboard", - chartType: ChartType.ColumnClustered, - left: 500, - top: 100 -); - -// PivotChart automatically syncs with PivotTable -// To add data series, use pivottable tool: -await pivotCommands.AddValueField(batch, "SalesPivot", "Revenue", AggregationFunction.Sum); -// PivotChart updates automatically! -``` - ---- - -## Success Criteria - -### Phase 1 (MVP) - 🚧 IN PROGRESS - -**Lifecycle Operations (6/6):** -- 🚧 `List` - List all charts -- 🚧 `Read` - Get chart configuration -- 🚧 `CreateFromRange` - Create Regular Chart -- 🚧 `CreateFromPivotTable` - Create PivotChart -- 🚧 `Delete` - Delete chart -- 🚧 `Move` - Move/resize chart - -**Data Source Operations (3/3):** -- 🚧 `SetSourceRange` - Set range (Regular only) -- 🚧 `AddSeries` - Add series (Regular only, PivotChart error) -- 🚧 `RemoveSeries` - Remove series (Regular only, PivotChart error) - -**Appearance Operations (5/5):** -- 🚧 `SetChartType` - Change chart type (all 70+ types) -- 🚧 `SetTitle` - Set chart title -- 🚧 `SetAxisTitle` - Set axis title -- 🚧 `ShowLegend` - Show/hide legend -- 🚧 `SetStyle` - Apply chart style - -**Integration:** -- 🚧 MCP Server tool (`chart` with ~15 actions) -- 🚧 CLI commands (all 15 operations) -- 🚧 Integration tests with both chart types - -### Future Enhancements (Phase 2) - -- Advanced formatting (colors, fonts, borders) -- Axis scaling and formatting -- Data labels -- Trendlines - - ---- - -## Implementation Timeline - -**Phase 1 (Core Operations): 🚧 IN PROGRESS** (November 22, 2025 - December 2025) -- Specification and interface design -- Strategy pattern implementation -- Core lifecycle and appearance operations -- MCP Server and CLI integration -- Integration tests -- **Estimated Time:** 1-2 weeks - -**Phase 2 (Advanced Features): ⏸️ FUTURE** (On demand) -- Advanced formatting and customization -- Chart export capabilities -- **Estimated Time:** 1 week when prioritized - ---- - -## Open Questions - -1. **Chart naming** - Excel auto-generates names like "Chart 1". Should we force users to provide names or auto-generate meaningful ones? - -2. **Default positioning** - Should we have a smart default positioning algorithm (e.g., place charts below/beside data) or always require explicit coordinates? - -3. **Chart refresh** - PivotCharts auto-refresh. Should we expose a `Refresh` operation for Regular Charts to re-read source data? - -4. **Chart export** - Should chart-to-image export be Phase 1 or Phase 2? - -**Recommended Answers:** -1. **Optional names** - Auto-generate meaningful names like "Chart_Data_A1D10" if not provided -2. **Explicit coordinates** - Always require positioning (LLMs can calculate, prevents unexpected placement) -3. **No Refresh for Regular** - Charts update automatically when data changes. Not needed. -4. **Phase 2** - Export is advanced feature, not core automation diff --git a/specs/COMPILE-TIME-CONSISTENCY-SPEC.md b/specs/COMPILE-TIME-CONSISTENCY-SPEC.md deleted file mode 100644 index 331fbe68..00000000 --- a/specs/COMPILE-TIME-CONSISTENCY-SPEC.md +++ /dev/null @@ -1,309 +0,0 @@ -# Compile-Time Consistency: What Was Implemented - -> **Status**: ✅ COMPLETED (February 2026) - -## Overview - -This document describes the **code generation system** that ensures consistency between Core interfaces, CLI commands, and MCP tools. - -**Single Source of Truth**: Core interface methods annotated with `[ServiceCategory]` and `[ServiceAction]` attributes. - -**Generated Code**: A Roslyn source generator produces `ServiceRegistry.{Category}.g.cs` files with: -- Action enums -- String constants -- CLI Settings classes -- Routing methods - ---- - -## Architecture Diagram - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ CORE (Source of Truth) │ -│ │ -│ [ServiceCategory("powerquery")] │ -│ public interface IPowerQueryCommands │ -│ { │ -│ [ServiceAction("list")] │ -│ List<QueryInfo> List(IExcelBatch batch); │ -│ │ -│ [ServiceAction("create")] │ -│ void Create(IExcelBatch batch, string queryName, string mCode, ...); │ -│ } │ -└──────────────────────────────────┬──────────────────────────────────────────┘ - │ - ┌──────────────▼──────────────┐ - │ SOURCE GENERATOR │ - │ (ExcelMcp.Generators) │ - └──────────────┬──────────────┘ - │ - ┌─────────────────────────┼─────────────────────────┐ - │ │ │ - ▼ ▼ ▼ -┌─────────────────┐ ┌─────────────────────┐ ┌─────────────────────────┐ -│ ENUM GENERATED │ │ CLI USES GENERATED │ │ MCP USES GENERATED │ -│ │ │ │ │ │ -│ PowerQueryAction│ │ CliSettings class │ │ RouteAction() method │ -│ - List │ │ RouteCliArgs() │ │ Forward{Action}() │ -│ - Create │ │ ValidActions[] │ │ │ -│ - Refresh │ │ │ │ │ -└─────────────────┘ └─────────────────────┘ └─────────────────────────┘ - │ │ │ - └─────────────────────────┼─────────────────────────┘ - │ - ┌──────────────▼──────────────┐ - │ ALL BUILD THE SAME │ - │ (command, args) TUPLE │ - └──────────────┬──────────────┘ - │ - ┌──────────────▼──────────────┐ - │ SERVICE (ExcelMcpService)│ - │ Routes to Core Commands │ - └─────────────────────────────┘ -``` - ---- - -## What Gets Generated - -For each `[ServiceCategory]` interface, the generator produces `ServiceRegistry.{Category}.g.cs`: - -### 1. Action Enum -```csharp -public enum PowerQueryAction -{ - List, - View, - Create, - Update, - Delete, - Refresh, - // ... one per [ServiceAction] method -} -``` - -### 2. String Constants -```csharp -public const string Category = "powerquery"; -public const string McpToolName = "powerquery"; - -// Action constants -public const string ListAction = "list"; -public const string CreateAction = "create"; - -// Full command strings -public const string ListCommand = "powerquery.list"; -public const string CreateCommand = "powerquery.create"; - -public static readonly string[] ValidActions = ["list", "view", "create", ...]; -``` - -### 3. Parsing/Conversion Methods -```csharp -// String → Enum -public static bool TryParseAction(string actionString, out PowerQueryAction action) - -// Enum → String -public static string ToActionString(PowerQueryAction action) -``` - -### 4. CLI Settings Class -```csharp -public sealed class CliSettings : Spectre.Console.Cli.CommandSettings -{ - [CommandArgument(0, "<ACTION>")] - public string Action { get; init; } = string.Empty; - - [CommandOption("-s|--session <SESSION>")] - public string SessionId { get; init; } = string.Empty; - - [CommandOption("--query-name <QUERYNAME>")] - public string? QueryName { get; init; } - - [CommandOption("--m-code <MCODE>")] - public string? MCode { get; init; } - - // ... all parameters from all methods in the interface -} -``` - -### 5. CLI Routing Method -```csharp -public static (string Command, object? Args) RouteCliArgs( - string action, - string? queryName = null, - string? mCode = null, - // ... all parameters -) -{ - var command = $"powerquery.{action}"; - object? args = action switch - { - "list" => null, - "create" => new { queryName, mCode, loadDestination, ... }, - // ... one case per action - }; - return (command, args); -} -``` - -### 6. MCP Routing Method -```csharp -public static string RouteAction( - PowerQueryAction action, - string sessionId, - Func<string, string, object?, string> forwardToService, - // ... all parameters -) -{ - return action switch - { - PowerQueryAction.List => ForwardList(sessionId, forwardToService), - PowerQueryAction.Create => ForwardCreate(sessionId, forwardToService, queryName, mCode, ...), - // ... - }; -} -``` - -### 7. Forward Methods (one per action) -```csharp -public static string ForwardList(string sessionId, Func<...> forwardToService) -{ - return forwardToService("powerquery.list", sessionId, null); -} - -public static string ForwardCreate( - string sessionId, - Func<...> forwardToService, - string? queryName = null, - string? mCode = null, - // ... -) -{ - ParameterTransforms.RequireNotEmpty(queryName, "queryName", "create"); - return forwardToService("powerquery.create", sessionId, new - { - QueryName = queryName, - MCode = mCode, - // ... - }); -} -``` - ---- - -## How CLI Uses Generated Code - -**Before (manual)**: -```csharp -// Each CLI command had to manually build args objects matching Service expectations -``` - -**After (generated)**: -```csharp -internal sealed class PowerQueryCommand : ServiceCommandBase<ServiceRegistry.PowerQuery.CliSettings> -{ - protected override string? GetSessionId(CliSettings s) => s.SessionId; - protected override string? GetAction(CliSettings s) => s.Action; - protected override IReadOnlyList<string> ValidActions => ServiceRegistry.PowerQuery.ValidActions; - - protected override (string command, object? args) Route(CliSettings settings, string action) - { - return ServiceRegistry.PowerQuery.RouteCliArgs( - action, - queryName: settings.QueryName, - mCode: settings.MCode, - // ... settings properties map 1:1 to generated parameters - ); - } -} -``` - ---- - -## How MCP Uses Generated Code - -**Before (manual)**: -```csharp -// Each MCP tool had switch statements calling ForwardToService with string literals -return ExcelToolsBase.ForwardToService("powerquery.create", sessionId, new { queryName, mCode }); -``` - -**After (generated)**: -```csharp -public static partial string ExcelPowerQuery(PowerQueryAction action, string sessionId, ...) -{ - return ExcelToolsBase.ExecuteToolAction( - "powerquery", - ServiceRegistry.PowerQuery.ToActionString(action), - () => ServiceRegistry.PowerQuery.RouteAction( - action, - sessionId, - ExcelToolsBase.ForwardToServiceFunc, - queryName: queryName, - mCode: mCode, - // ... MCP parameters map 1:1 to generated parameters - )); -} -``` - ---- - -## Compile-Time Guarantees - -| What | How It's Enforced | -|------|-------------------| -| Action enum values exist for all Core methods | Generator reads `[ServiceAction]` attributes | -| CLI settings have properties for all parameters | Generator extracts method parameters | -| RouteCliArgs covers all actions | Generator creates switch case per method | -| RouteAction covers all enum values | Generator creates switch case per enum | -| Parameter names match across layers | Same generator produces all routing code | -| String constants are consistent | Generated once from source of truth | - ---- - -## What's NOT Generated (By Design) - -1. **Service routing logic** - `ExcelMcpService.cs` still has manual switch statements (routes to Core) -2. **MCP tool class definition** - The `[McpServerTool]` attribute and parameter list are manual -3. **McpMeta attributes** - Static metadata for MCP clients, not derived from interface - ---- - -## Generator Projects - -| Project | Purpose | -|---------|---------| -| `ExcelMcp.Generators` | Main generator - produces ServiceRegistry files (validation, dispatch, helpers) | -| `ExcelMcp.Generators.Shared` | Shared models (ServiceInfo, MethodInfo, ParameterInfo) | -| `ExcelMcp.Generators.Cli` | CLI command generator - produces per-category CLI command classes | - -**Limitation**: Roslyn source generators can only see the project they're attached to. We attach to Core, so we can only generate into Core (the ServiceRegistry namespace). - ---- - -## Service Routing: Fully Generated - -The Service (`ExcelMcpService.cs`) uses **generated dispatch** methods to route commands to Core. -The generator produces `ServiceRegistry.{Category}.DispatchToCore()` methods that handle: - -- JSON deserialization of arguments into typed args classes -- Enum parsing with hyphen/underscore stripping -- Per-method batch parameter inclusion (`HasBatchParameter`) -- Return type handling (void vs data) - -```csharp -// Generated dispatch - no manual routing needed -private Task<ServiceResponse> DispatchSimpleAsync<TAction>( - string category, string action, ServiceRequest request) - where TAction : struct, Enum -{ - // Uses ServiceRegistry.{Category}.DispatchToCore(commands, action, batch, argsJson) -} -``` - -**Testing strategy**: Pre-commit scripts verify coverage: -- All enum actions have Core method implementations (`check-mcp-core-implementations.ps1`) -- All CLI actions have handlers (`check-cli-action-coverage.ps1`) - diff --git a/specs/DATA-MODEL-DAX-FEATURE-SPEC.md b/specs/DATA-MODEL-DAX-FEATURE-SPEC.md deleted file mode 100644 index c2c929cc..00000000 --- a/specs/DATA-MODEL-DAX-FEATURE-SPEC.md +++ /dev/null @@ -1,1875 +0,0 @@ -# Data Model and DAX Management Feature Specification - -## Overview - -Add comprehensive Data Model (PowerPivot) and DAX management capabilities to ExcelMcp, enabling programmatic manipulation of Excel's embedded Tabular data model. This provides AI-assisted development workflows for business intelligence features including measures, calculated columns, relationships, and perspectives. - -## Objectives - -1. Provide CRUD operations for Data Model objects (tables, measures, relationships) -2. Support DAX expression management and validation -3. Enable AI-assisted BI development workflows through MCP Server -4. Maintain architectural consistency with existing PowerQuery and Connection patterns -5. Support model deployment and version control scenarios - -## Strategic Context - -**Relationship to Existing Features:** - -- **PowerQuery Integration:** Queries can load directly to Data Model (`SetLoadToDataModel`) -- **Connection Management:** ModelConnection type (Type 7) already recognized -- **Architectural Alignment:** Follow same pattern as PowerQuery and Connection commands - -**Development Focus (NOT ETL):** - -This feature is for **Data Model development and automation**, not production BI operations: - -- DAX measure development and refactoring -- Model relationship configuration -- Schema deployment and versioning -- AI-assisted DAX optimization -- Documentation generation for measures - -## Research: Excel Data Model COM API Capabilities - -### Analysis Services Tabular API Access - -Excel's Data Model is a **lightweight Analysis Services Tabular model** embedded in the workbook. Access requires **two-layer approach**: - -#### Layer 1: Excel COM API (Limited Data Model Access) - -**Workbook.Model Object** (Excel 2013+): -```csharp -dynamic model = workbook.Model; -``` - -**Available Properties/Methods:** -- `ModelTables` - Collection of tables in the model -- `ModelRelationships` - Collection of relationships -- `ModelMeasures` - Collection of DAX measures -- `ModelFormatBoolean` - Boolean formatting settings -- `ModelFormatDecimalNumber` - Number formatting -- `ModelFormatWholeNumber` - Integer formatting -- `Refresh()` - Refresh all model connections -- `Initialize()` - Initialize the model - -**ModelTable Object:** -- `Name` - Table name (Read-Only) -- `SourceName` - Source query/connection (Read-Only) -- `ModelTableColumns` - Column collection -- `RefreshConnection()` - Refresh this table -- `RecordCount` - Row count (Read-Only) - -**ModelMeasure Object** (CRITICAL for DAX): -- `Name` - Measure name -- `Formula` - DAX expression -- `Description` - Measure description -- `FormatInformation` - Number formatting -- `Associated Table` - Parent table reference - -**ModelRelationship Object:** -- `ForeignKeyColumn` - Source column -- `PrimaryKeyColumn` - Target column -- `Active` - Whether relationship is active - -**LIMITATION:** Excel COM API provides **basic access only** - no calculated columns, hierarchies, or advanced model features. - ---- - -#### Layer 2: Analysis Services Tabular (TOM) API - -For **full Data Model manipulation**, we need the **Tabular Object Model (TOM)**: - -**NuGet Package:** -```xml -<PackageReference Include="Microsoft.AnalysisServices.AdomdClient.NetCore.retail.amd64" Version="19.x" /> -<PackageReference Include="Microsoft.AnalysisServices.NetCore.retail.amd64" Version="19.x" /> -``` - -**Connection Pattern:** -```csharp -// Connect to embedded Data Model -string connectionString = $"Data Source={excelFilePath};Provider=MSOLAP"; -using var connection = new AdomdConnection(connectionString); -connection.Open(); - -// Access Tabular Object Model -Server server = new Server(); -server.Connect(connectionString); -Database database = server.Databases[0]; // Embedded model -Model model = database.Model; -``` - -**TOM Capabilities (Full Access):** -- **Tables:** Add, modify, delete tables -- **Measures:** Create DAX measures with full formatting -- **Calculated Columns:** DAX expressions for computed columns -- **Calculated Tables:** DAX table expressions -- **Relationships:** Define, modify, delete relationships -- **Hierarchies:** Create drill-down hierarchies -- **Perspectives:** Create filtered views of model -- **Roles:** Row-level security (RLS) configuration -- **Partitions:** Table data partitioning -- **Annotations:** Metadata storage - -**TOM DAX Support:** -```csharp -// Create measure via TOM -var measure = new Microsoft.AnalysisServices.Tabular.Measure -{ - Name = "Total Sales", - Expression = "SUM('Sales'[Amount])", - FormatString = "$#,##0.00", - Description = "Sum of all sales amounts" -}; -table.Measures.Add(measure); -model.SaveChanges(); -``` - ---- - -### Hybrid Approach: Excel COM + TOM - -**Recommendation:** Use **both APIs** depending on operation complexity: - -| Operation | Recommended API | Reason | -|-----------|----------------|---------| -| List measures | Excel COM | Simple, fast, lightweight | -| View measure DAX | Excel COM | Read-only, no dependencies | -| Create measure | TOM | Full control, validation | -| Update measure | TOM | DAX syntax validation | -| Delete measure | Either | Both support deletion | -| List relationships | Excel COM | Quick enumeration | -| Create relationship | TOM | Better validation | -| Refresh model | Excel COM | Direct workbook control | -| Add calculated column | TOM | Only available via TOM | -| Create hierarchy | TOM | Only available via TOM | - ---- - -## Verified COM API Capabilities - -### Workbook.Model Object (Excel 2013+) - -**Accessible via Excel COM:** -```csharp -dynamic workbook = excel.Workbooks.Open(filePath); -dynamic model = workbook.Model; -``` - -**Model Object Properties:** -- `ModelTables` - Collection of tables in Data Model ✅ -- `ModelRelationships` - Collection of relationships ✅ -- `ModelMeasures` - Collection of DAX measures ✅ -- `DataModelConnection` - Connection to embedded model ✅ -- `ModelFormatBoolean` - Boolean formatting ✅ -- `ModelFormatCurrency` - Currency formatting ✅ -- `ModelFormatDate` - Date formatting ✅ -- `ModelFormatDecimalNumber` - Decimal formatting ✅ -- `ModelFormatPercentageNumber` - Percentage formatting ✅ -- `ModelFormatScientificNumber` - Scientific notation ✅ -- `ModelFormatWholeNumber` - Integer formatting ✅ - -**Model Methods:** -- `Initialize()` - Initialize the Data Model ✅ -- `Refresh()` - Refresh all model data ✅ -- `CreateModelWorkbookConnection(connectionString)` - Create connection ✅ -- `AddConnection(connectionObject)` - Add existing connection ✅ - -### ModelTable Object - -**Properties:** -- `Name` - Table name (Read-Only) ✅ -- `SourceName` - Source query/connection name ✅ -- `ModelTableColumns` - Columns collection ✅ -- `RecordCount` - Number of rows (Read-Only) ✅ -- `RefreshDate` - Last refresh timestamp ✅ -- `SourceWorkbookConnection` - Source connection object ✅ - -**Methods:** -- `Refresh()` - Refresh table data ✅ - -**Limitations:** -- Cannot create tables via Excel COM (use TOM or Power Query) -- Cannot delete tables directly (use TOM) -- Limited column metadata access - -### ModelMeasure Object (CRITICAL for DAX) - -**Properties:** -- `Name` - Measure name ✅ -- `AssociatedTable` - Parent table reference ✅ -- `Formula` - DAX expression ✅ -- `FormatInformation` - Formatting object ✅ -- `Description` - Measure description ✅ - -**Methods:** -- `Delete()` - Remove measure ✅ - -**Creating Measures via Excel COM:** -```csharp -dynamic modelTables = model.ModelTables; -dynamic targetTable = modelTables.Item("Sales"); -dynamic measures = targetTable.ModelMeasures; - -// Add new measure -dynamic newMeasure = measures.Add( - Name: "Total Revenue", - Formula: "=SUM(Sales[Amount])", - Description: "Sum of all sales amounts" -); -``` - -**⚠️ CRITICAL LIMITATION:** Excel COM measure creation is **limited**: -- Basic DAX formulas only -- Limited formatting control -- No calculated columns support -- No validation feedback - -**Recommendation:** Use **TOM API for measure creation**, Excel COM for **listing/viewing only**. - -### ModelRelationship Object - -**Properties:** -- `ForeignKeyColumn` - Source column object ✅ -- `PrimaryKeyColumn` - Target column object ✅ -- `Active` - Whether relationship is active ✅ - -**Methods:** -- `Delete()` - Remove relationship ✅ - -**Creating Relationships:** -```csharp -dynamic relationships = model.ModelRelationships; -relationships.Add( - ForeignKeyColumn: salesTable.ModelTableColumns("CustomerID"), - PrimaryKeyColumn: customersTable.ModelTableColumns("ID") -); -``` - -### ModelColumn Object - -**Properties:** -- `Name` - Column name ✅ -- `DataType` - Data type enum ✅ -- `SourceColumn` - Source column name ✅ -- `NumberFormat` - Formatting string ✅ - -**Limitations:** -- Cannot create calculated columns via Excel COM -- Cannot modify column properties extensively -- Use TOM for advanced column operations - ---- - -## Feature Design - -### Architecture Decision: Dual-API Approach - -**Strategy:** Implement **two command sets** with clear separation: - -1. **Basic Commands** (`model-*`) - Excel COM only - - Fast, lightweight operations - - No external dependencies - - Read-only or simple modifications - - List, view, refresh operations - -2. **Advanced Commands** (`dax-*`) - TOM API required - - Full Data Model manipulation - - DAX expression validation - - Create measures, calculated columns, hierarchies - - Requires TOM NuGet packages - -**Benefit:** Users can use basic features without TOM, advanced users get full power. - ---- - -## Functional Requirements - -### Model Commands (Excel COM - Basic Operations) - -#### 1. List Model Tables (`model-list-tables`) - -**Purpose:** Display all tables in the Data Model - -**CLI Usage:** -```powershell -excelcli model-list-tables "workbook.xlsx" -``` - -**Output:** Table showing: -- Table Name -- Source (Query name or connection) -- Record Count -- Last Refresh Date - -**Implementation:** -```csharp -dynamic model = workbook.Model; -dynamic modelTables = model.ModelTables; -for (int i = 1; i <= modelTables.Count; i++) -{ - dynamic table = modelTables.Item(i); - // Access: Name, SourceName, RecordCount, RefreshDate -} -``` - ---- - -#### 2. List Model Measures (`model-list-measures`) - -**Purpose:** Display all DAX measures in the model - -**CLI Usage:** -```powershell -excelcli model-list-measures "workbook.xlsx" -excelcli model-list-measures "workbook.xlsx" --table "Sales" # Filter by table -``` - -**Output:** Table showing: -- Measure Name -- Table -- Formula (preview) -- Description - -**Implementation:** -```csharp -dynamic modelTables = model.ModelTables; -for (int i = 1; i <= modelTables.Count; i++) -{ - dynamic table = modelTables.Item(i); - dynamic measures = table.ModelMeasures; - for (int m = 1; m <= measures.Count; m++) - { - dynamic measure = measures.Item(m); - // Access: Name, Formula, Description - } -} -``` - ---- - -#### 3. Read Measure DAX (`model-read-measure`) - -**Purpose:** Display complete measure details and DAX formula - -**CLI Usage:** -```powershell -excelcli model-read-measure "workbook.xlsx" "Total Sales" -``` - -**Output:** -- Measure Name -- Associated Table -- Full DAX Formula -- Description -- Format Information -- Character Count - -**Implementation:** -```csharp -dynamic measure = FindMeasure(model, measureName); -string formula = measure.Formula; -string description = measure.Description ?? ""; -dynamic formatInfo = measure.FormatInformation; -``` - ---- - -#### 4. Export Measure DAX (`model-export-measure`) - -**Purpose:** Export DAX formula to file for version control - -**CLI Usage:** -```powershell -excelcli model-export-measure "workbook.xlsx" "Total Sales" "total-sales.dax" -``` - -**Output:** DAX file with measure metadata as comments - -**Format:** -```dax --- Measure: Total Sales --- Table: Sales --- Description: Sum of all sales amounts --- Format: Currency - -Total Sales := -SUM('Sales'[Amount]) -``` - ---- - -#### 5. List Model Relationships (`model-list-relationships`) - -**Purpose:** Display all table relationships - -**CLI Usage:** -```powershell -excelcli model-list-relationships "workbook.xlsx" -``` - -**Output:** Table showing: -- From Table.Column -- To Table.Column -- Active (Yes/No) -- Cardinality (if accessible) - -**Implementation:** -```csharp -dynamic relationships = model.ModelRelationships; -for (int i = 1; i <= relationships.Count; i++) -{ - dynamic rel = relationships.Item(i); - dynamic fkColumn = rel.ForeignKeyColumn; - dynamic pkColumn = rel.PrimaryKeyColumn; - bool isActive = rel.Active; -} -``` - ---- - -#### 6. Refresh Model (`model-refresh`) - -**Purpose:** Refresh all Data Model tables - -**CLI Usage:** -```powershell -excelcli model-refresh "workbook.xlsx" -excelcli model-refresh "workbook.xlsx" --table "Sales" # Refresh specific table -``` - -**Implementation:** -```csharp -// Refresh entire model -model.Refresh(); - -// OR refresh specific table -dynamic table = FindModelTable(model, tableName); -table.Refresh(); -``` - -**⚠️ WARNING:** Avoid `model.Refresh()` if it hangs (similar to `workbook.RefreshAll()` issue). May need per-table refresh. - ---- - -### DAX Commands (TOM API - Advanced Operations) - -**Prerequisite:** Requires TOM NuGet packages and Analysis Services runtime - -#### 1. Create Measure (`dax-create-measure`) - -**Purpose:** Create new DAX measure with full validation - -**CLI Usage:** -```powershell -excelcli dax-create-measure "workbook.xlsx" "Sales" "Total Revenue" "revenue.dax" -excelcli dax-create-measure "workbook.xlsx" "Sales" "Total Revenue" --formula "SUM(Sales[Amount])" -``` - -**JSON Definition File (`measure-def.json`):** -```json -{ - "name": "Total Revenue", - "table": "Sales", - "formula": "SUM('Sales'[Amount])", - "description": "Sum of all sales amounts", - "formatString": "$#,##0.00", - "isHidden": false -} -``` - -**Implementation (TOM):** -```csharp -Server server = new Server(); -server.Connect($"Data Source={excelFilePath};Provider=MSOLAP"); -Database database = server.Databases[0]; -Model model = database.Model; - -var table = model.Tables[tableName]; -var measure = new Microsoft.AnalysisServices.Tabular.Measure -{ - Name = measureName, - Expression = daxFormula, - Description = description, - FormatString = formatString -}; - -table.Measures.Add(measure); -model.SaveChanges(); -``` - ---- - -#### 2. Update Measure DAX (`dax-update-measure`) - -**Purpose:** Modify existing measure formula and properties - -**CLI Usage:** -```powershell -excelcli dax-update-measure "workbook.xlsx" "Total Sales" "updated-sales.dax" -excelcli dax-update-measure "workbook.xlsx" "Total Sales" --formula "CALCULATE(SUM(Sales[Amount]))" -``` - -**Implementation (TOM):** -```csharp -var measure = FindMeasure(model, measureName); -measure.Expression = newDaxFormula; -measure.Description = description; -model.SaveChanges(); -``` - ---- - -#### 3. Delete Measure (`dax-delete-measure`) - -**Purpose:** Remove measure from model - -**CLI Usage:** -```powershell -excelcli dax-delete-measure "workbook.xlsx" "Old Measure" -``` - -**Implementation (TOM preferred, Excel COM fallback):** -```csharp -// Via TOM (preferred) -var measure = FindMeasure(model, measureName); -measure.Delete(); -model.SaveChanges(); - -// Via Excel COM (fallback) -dynamic measure = FindMeasure(workbook.Model, measureName); -measure.Delete(); -``` - ---- - -#### 4. Validate DAX (`dax-validate`) - -**Purpose:** Validate DAX expression syntax without creating measure - -**CLI Usage:** -```powershell -excelcli dax-validate "workbook.xlsx" "SUM(Sales[Amount])" -excelcli dax-validate "workbook.xlsx" "formula.dax" -``` - -**Output:** -- Valid: Yes/No -- Error Message (if invalid) -- Suggested Corrections - -**Implementation (TOM):** -```csharp -// Create temporary measure to validate -var tempMeasure = new Measure -{ - Name = "_ValidationTemp", - Expression = daxExpression -}; - -try -{ - table.Measures.Add(tempMeasure); - model.SaveChanges(); - // Valid! - tempMeasure.Delete(); - model.SaveChanges(); -} -catch (Exception ex) -{ - // Parse error message for DAX syntax errors - return ParseDaxError(ex.Message); -} -``` - ---- - -#### 5. Create Relationship (`dax-create-relationship`) - -**Purpose:** Define table relationships - -**CLI Usage:** -```powershell -excelcli dax-create-relationship "workbook.xlsx" "Sales.CustomerID" "Customers.ID" -excelcli dax-create-relationship "workbook.xlsx" "Sales.CustomerID" "Customers.ID" --inactive -``` - -**Implementation (TOM):** -```csharp -var salesTable = model.Tables["Sales"]; -var customersTable = model.Tables["Customers"]; - -var relationship = new SingleColumnRelationship -{ - FromColumn = salesTable.Columns["CustomerID"], - ToColumn = customersTable.Columns["ID"], - IsActive = true -}; - -model.Relationships.Add(relationship); -model.SaveChanges(); -``` - ---- - -#### 6. Delete Relationship (`dax-delete-relationship`) - -**Purpose:** Remove table relationship - -**CLI Usage:** -```powershell -excelcli dax-delete-relationship "workbook.xlsx" "Sales.CustomerID" "Customers.ID" -``` - ---- - -#### 7. Create Calculated Column (`dax-create-column`) - -**Purpose:** Add DAX calculated column to table - -**CLI Usage:** -```powershell -excelcli dax-create-column "workbook.xlsx" "Sales" "Profit" --formula "[Revenue] - [Cost]" -``` - -**Implementation (TOM only - not available in Excel COM):** -```csharp -var table = model.Tables["Sales"]; -var column = new CalculatedColumn -{ - Name = "Profit", - Expression = "[Revenue] - [Cost]", - DataType = DataType.Decimal, - FormatString = "$#,##0.00" -}; - -table.Columns.Add(column); -model.SaveChanges(); -``` - ---- - -#### 8. Export Model Schema (`model-export-schema`) - -**Purpose:** Export complete model definition for version control - -**CLI Usage:** -```powershell -excelcli model-export-schema "workbook.xlsx" "model-schema.json" -``` - -**Output (JSON):** -```json -{ - "tables": [ - { - "name": "Sales", - "columns": ["Date", "CustomerID", "Amount"], - "measures": [ - { - "name": "Total Sales", - "formula": "SUM(Sales[Amount])", - "formatString": "$#,##0.00" - } - ] - } - ], - "relationships": [ - { - "from": "Sales.CustomerID", - "to": "Customers.ID", - "active": true - } - ] -} -``` - ---- - -#### 9. Import Model Schema (`model-import-schema`) - -**Purpose:** Create measures and relationships from JSON definition - -**CLI Usage:** -```powershell -excelcli model-import-schema "workbook.xlsx" "model-schema.json" -``` - -**Use Case:** Deploy model changes across workbooks, version control - ---- - -## MCP Server Integration - -### Tool 1: `excel_data_model` (Basic Operations) - -**Description:** Manage Excel Data Model using Excel COM API - -**Actions:** -- `list-tables` - List all model tables -- `list-measures` - List all DAX measures -- `read` - Display measure DAX formula -- `export-measure` - Export measure to DAX file -- `list-relationships` - Display table relationships -- `refresh` - Refresh model data -- `refresh-table` - Refresh specific table - -**Input Schema:** -```json -{ - "action": "list-measures | read | export-measure | ...", - "excelPath": "path/to/workbook.xlsx", - "measureName": "optional", - "tableName": "optional", - "outputPath": "optional" -} -``` - ---- - -### Tool 2: `excel_dax` (Advanced Operations - Requires TOM) - -**Description:** Advanced DAX and Data Model manipulation using TOM API - -**Actions:** -- `create-measure` - Create new DAX measure -- `update-measure` - Modify measure formula -- `delete-measure` - Remove measure -- `validate` - Validate DAX expression -- `create-relationship` - Define table relationship -- `delete-relationship` - Remove relationship -- `create-column` - Add calculated column -- `export-schema` - Export model definition -- `import-schema` - Import model definition - -**Input Schema:** -```json -{ - "action": "create-measure | update-measure | validate | ...", - "excelPath": "path/to/workbook.xlsx", - "measureName": "optional", - "tableName": "optional", - "daxFormula": "optional", - "formatString": "optional", - "schemaPath": "optional" -} -``` - -**Prerequisite Check:** -```csharp -// Check if TOM libraries are available -try -{ - var _ = typeof(Microsoft.AnalysisServices.Tabular.Server); - return true; // TOM available -} -catch -{ - throw new InvalidOperationException( - "Advanced DAX operations require Analysis Services Tabular Object Model (TOM). " + - "Install NuGet package: Microsoft.AnalysisServices.NetCore.retail.amd64" - ); -} -``` - ---- - -## Development-Focused Use Cases - -### AI-Assisted DAX Development - -**Scenario:** Developer wants to optimize slow DAX measure - -```text -Developer: "This measure is slow: Total Sales := SUM(Sales[Amount]). Can you optimize it?" -Copilot: [Uses excel_data_model read -> analyzes DAX -> suggests optimization] - "Your measure uses table scan. Consider this optimized version using CALCULATE: - Total Sales := CALCULATE(SUM(Sales[Amount]), REMOVEFILTERS(Sales[Date]))" -Developer: "Apply the optimization" -Copilot: [Uses excel_dax update-measure with optimized formula] -``` - -### Model Documentation Generation - -```text -Developer: "Generate documentation for all measures in this model" -Copilot: [Uses excel_data_model list-measures -> export each measure] - "Exported 15 measures to /docs/measures/*.dax with descriptions" -``` - -### Model Deployment Automation - -```text -Developer: "Deploy the model schema from dev to prod workbook" -Copilot: [Uses excel_dax export-schema on dev.xlsx] - [Uses excel_dax import-schema on prod.xlsx] - "Model schema deployed: 12 measures, 5 relationships created" -``` - ---- - -## Architecture & Implementation - -### Shared Utilities (ExcelHelper.cs) - -```csharp -/// <summary> -/// Finds a model measure by name across all tables -/// </summary> -public static dynamic? FindModelMeasure(dynamic model, string measureName) -{ - dynamic? modelTables = null; - try - { - modelTables = model.ModelTables; - for (int t = 1; t <= modelTables.Count; t++) - { - dynamic? table = null; - dynamic? measures = null; - try - { - table = modelTables.Item(t); - measures = table.ModelMeasures; - - for (int m = 1; m <= measures.Count; m++) - { - dynamic? measure = null; - try - { - measure = measures.Item(m); - if (measure.Name.Equals(measureName, StringComparison.OrdinalIgnoreCase)) - { - var result = measure; - measure = null; // Don't release - returning it - return result; - } - } - finally - { - if (measure != null) ReleaseComObject(ref measure); - } - } - } - finally - { - ReleaseComObject(ref measures); - ReleaseComObject(ref table); - } - } - } - finally - { - ReleaseComObject(ref modelTables); - } - return null; -} - -/// <summary> -/// Gets all measure names from model -/// </summary> -public static List<string> GetModelMeasureNames(dynamic model) -{ - var names = new List<string>(); - dynamic? modelTables = null; - try - { - modelTables = model.ModelTables; - for (int t = 1; t <= modelTables.Count; t++) - { - dynamic? table = null; - dynamic? measures = null; - try - { - table = modelTables.Item(t); - measures = table.ModelMeasures; - - for (int m = 1; m <= measures.Count; m++) - { - dynamic? measure = null; - try - { - measure = measures.Item(m); - names.Add(measure.Name); - } - finally - { - ReleaseComObject(ref measure); - } - } - } - finally - { - ReleaseComObject(ref measures); - ReleaseComObject(ref table); - } - } - } - finally - { - ReleaseComObject(ref modelTables); - } - return names; -} - -/// <summary> -/// Checks if workbook has Data Model -/// </summary> -public static bool HasDataModel(dynamic workbook) -{ - try - { - dynamic model = workbook.Model; - bool hasModel = model != null; - ReleaseComObject(ref model); - return hasModel; - } - catch - { - return false; - } -} -``` - ---- - -### Core Commands Interface - -```csharp -// Commands/IDataModelCommands.cs -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Data Model management commands - Basic operations using Excel COM API -/// </summary> -public interface IDataModelCommands -{ - /// <summary> - /// Lists all tables in the Data Model - /// </summary> - DataModelTableListResult ListTables(string filePath); - - /// <summary> - /// Lists all DAX measures in the model - /// </summary> - DataModelMeasureListResult ListMeasures(string filePath, string? tableName = null); - - /// <summary> - /// Views complete measure details and DAX formula - /// </summary> - DataModelMeasureViewResult ViewMeasure(string filePath, string measureName); - - /// <summary> - /// Exports measure DAX formula to file - /// </summary> - void ExportMeasure(string filePath, string measureName, string outputFile); - - /// <summary> - /// Lists all table relationships - /// </summary> - DataModelRelationshipListResult ListRelationships(string filePath); - - /// <summary> - /// Refreshes entire Data Model or specific table - /// </summary> - RefreshResult Refresh(string filePath, string? tableName = null); -} -``` - ---- - -### Result Types - -```csharp -// Models/ResultTypes.cs - -/// <summary> -/// Result for listing Data Model tables -/// </summary> -public class DataModelTableListResult : ResultBase -{ - public List<DataModelTableInfo> Tables { get; set; } = new(); -} - -public class DataModelTableInfo -{ - public string Name { get; init; } = ""; - public string SourceName { get; init; } = ""; - public int RecordCount { get; init; } - public DateTime? RefreshDate { get; init; } -} - -/// <summary> -/// Result for listing DAX measures -/// </summary> -public class DataModelMeasureListResult : ResultBase -{ - public List<DataModelMeasureInfo> Measures { get; set; } = new(); -} - -public class DataModelMeasureInfo -{ - public string Name { get; init; } = ""; - public string Table { get; init; } = ""; - public string FormulaPreview { get; init; } = ""; - public string? Description { get; init; } -} - -/// <summary> -/// Result for viewing measure details -/// </summary> -public class DataModelMeasureViewResult : ResultBase -{ - public string MeasureName { get; set; } = ""; - public string TableName { get; set; } = ""; - public string DaxFormula { get; set; } = ""; - public string? Description { get; set; } - public string? FormatString { get; set; } - public int CharacterCount { get; set; } -} - -/// <summary> -/// Result for listing relationships -/// </summary> -public class DataModelRelationshipListResult : ResultBase -{ - public List<DataModelRelationshipInfo> Relationships { get; set; } = new(); -} - -public class DataModelRelationshipInfo -{ - public string FromTable { get; init; } = ""; - public string FromColumn { get; init; } = ""; - public string ToTable { get; init; } = ""; - public string ToColumn { get; init; } = ""; - public bool IsActive { get; init; } -} -``` - ---- - -## Implementation Phases - -### Phase 0: Research & Design (COMPLETE) - -- [x] Research Excel COM Model object capabilities -- [x] Research TOM API requirements and capabilities -- [x] Design dual-API architecture (Excel COM + TOM) -- [x] Create feature specification document -- [x] Define command interfaces and result types - ---- - -### Phase 1: Basic Operations (Excel COM Only) - -**Estimate:** 6-8 hours -**Priority:** HIGH -**Dependencies:** None (Excel COM is already available) - -**Deliverables:** - -1. **Core Commands Implementation:** - - [ ] Create `DataModelCommands.cs` implementing `IDataModelCommands` - - [ ] Implement `ListTables()` - enumerate model tables - - [ ] Implement `ListMeasures()` - enumerate DAX measures (all or by table) - - [ ] Implement `ViewMeasure()` - display measure details and DAX - - [ ] Implement `ExportMeasure()` - export DAX to file with metadata - - [ ] Implement `ListRelationships()` - enumerate relationships - - [ ] Implement `Refresh()` - refresh model or specific table - -2. **Shared Utilities:** - - [ ] Add `FindModelMeasure()` to `ExcelHelper.cs` - - [ ] Add `GetModelMeasureNames()` to `ExcelHelper.cs` - - [ ] Add `HasDataModel()` to `ExcelHelper.cs` - - [ ] Add `FindModelTable()` to `ExcelHelper.cs` - -3. **Result Types:** - - [ ] Add `DataModelTableListResult` to `ResultTypes.cs` - - [ ] Add `DataModelMeasureListResult` to `ResultTypes.cs` - - [ ] Add `DataModelMeasureViewResult` to `ResultTypes.cs` - - [ ] Add `DataModelRelationshipListResult` to `ResultTypes.cs` - -4. **Integration Tests:** - - [ ] Create `DataModelCommandsTests.cs` - - [ ] Test `ListTables()` with sample Data Model workbook - - [ ] Test `ListMeasures()` enumeration - - [ ] Test `ViewMeasure()` with various DAX formulas - - [ ] Test `ExportMeasure()` file output - - [ ] Test `ListRelationships()` detection - - [ ] Test `Refresh()` operations - -5. **Test Data:** - - [ ] Create `sample-datamodel.xlsx` with: - - 2-3 tables (Sales, Customers, Products) - - 5+ DAX measures (SUM, AVERAGE, CALCULATE examples) - - 2+ relationships - - Various format strings - ---- - -### Phase 2: CLI Integration - -**Estimate:** 4-6 hours -**Dependencies:** Phase 1 complete - -**Deliverables:** - -1. **CLI Commands:** - - [ ] Add `model-list-tables` command to `Program.cs` - - [ ] Add `model-list-measures` command - - [ ] Add `model-read-measure` command - - [ ] Add `model-export-measure` command - - [ ] Add `model-list-relationships` command - - [ ] Add `model-refresh` command - -2. **CLI Presentation Layer:** - - [ ] Create `DataModelCli.cs` with Spectre.Console formatting - - [ ] Implement table display for `ListTables()` - - [ ] Implement measure list display with formula previews - - [ ] Implement measure detail panel for `ViewMeasure()` - - [ ] Implement relationship table display - - [ ] Add progress indicators for refresh operations - -3. **CLI Tests:** - - [ ] Add CLI tests to `ExcelMcp.CLI.Tests` - - [ ] Test argument parsing for all commands - - [ ] Test output formatting - - [ ] Test error handling - -4. **Documentation:** - - [ ] Update user documentation with model commands - - [ ] Add usage examples - - [ ] Document prerequisites (Data Model required) - ---- - -### Phase 3: MCP Server Integration - -**Estimate:** 4-6 hours -**Dependencies:** Phase 2 complete - -**Deliverables:** - -1. **MCP Tool:** - - [ ] Create `ExcelDataModelTool.cs` in `Tools/` - - [ ] Implement action routing for 6 basic operations - - [ ] Add proper input validation and error handling - - [ ] Follow existing tool patterns (ExcelPowerQueryTool, ExcelConnectionTool) - -2. **MCP Server Configuration:** - - [ ] Update `server.json` with `excel_data_model` tool definition - - [ ] Add tool description and input schema - - [ ] Document action parameters - -3. **MCP Tests:** - - [ ] Add MCP integration tests - - [ ] Test JSON request/response format - - [ ] Test error scenarios - -4. **Documentation:** - - [ ] Update MCP Server README with Data Model examples - - [ ] Add AI assistant interaction examples - - [ ] Document development workflow use cases - ---- - -### Phase 4: Advanced Operations (TOM API) - -**Estimate:** 10-12 hours -**Priority:** MEDIUM (Future enhancement) -**Dependencies:** Phase 3 complete, TOM NuGet packages - -**Deliverables:** - -1. **TOM Integration:** - - [ ] Add NuGet package references: - - `Microsoft.AnalysisServices.AdomdClient.NetCore.retail.amd64` - - `Microsoft.AnalysisServices.NetCore.retail.amd64` - - [ ] Create `TomHelper.cs` utility class - - [ ] Implement TOM connection pattern - -2. **DAX Commands Interface:** - - [ ] Create `IDaxCommands.cs` interface - - [ ] Create `DaxCommands.cs` implementation - - [ ] Implement `CreateMeasure()` with validation - - [ ] Implement `UpdateMeasure()` with validation - - [ ] Implement `DeleteMeasure()` - - [ ] Implement `ValidateDax()` syntax checker - - [ ] Implement `CreateRelationship()` - - [ ] Implement `DeleteRelationship()` - - [ ] Implement `CreateCalculatedColumn()` - -3. **Schema Operations:** - - [ ] Implement `ExportSchema()` - JSON export - - [ ] Implement `ImportSchema()` - JSON import - - [ ] Define schema JSON format - - [ ] Add schema validation - -4. **CLI Integration:** - - [ ] Add `dax-*` commands to Program.cs - - [ ] Add TOM prerequisite checks - - [ ] Provide helpful error if TOM not available - -5. **MCP Tool:** - - [ ] Create `ExcelDaxTool.cs` - - [ ] Implement advanced action routing - - [ ] Add TOM availability detection - -6. **Tests:** - - [ ] TOM integration tests - - [ ] DAX validation tests - - [ ] Schema export/import tests - - [ ] Round-trip workflow tests - -7. **Documentation:** - - [ ] Document TOM requirements - - [ ] Add DAX command examples - - [ ] Update architecture docs - ---- - -## Security Considerations - -### DAX Expression Validation - -**Risk:** Malicious DAX expressions could cause performance issues or data access violations - -**Mitigation:** -- Always validate DAX syntax using TOM before applying -- Never execute DAX directly from untrusted sources -- Sanitize measure names and descriptions -- Document DAX best practices - -### Data Model Access Control - -**Risk:** Unauthorized access to sensitive business logic - -**Mitigation:** -- Require explicit workbook file access (existing file validation) -- No remote Data Model connections (local files only) -- Document that measures may contain sensitive business calculations - -### TOM API Security - -**Risk:** Analysis Services connection strings could expose credentials - -**Mitigation:** -- Only support embedded Data Models (no external connections) -- Connection string format: `Data Source={excelFilePath};Provider=MSOLAP` -- Never expose connection strings in logs or output -- Use integrated Windows authentication only - ---- - -## Limitations & Known Issues - -### Excel COM API Limitations - -1. **Cannot Create Tables:** Tables must be created via Power Query or external import -2. **No Calculated Columns:** Requires TOM API -3. **Limited Formatting Control:** Basic format strings only -4. **No Hierarchies:** Requires TOM API -5. **No Perspectives:** Requires TOM API -6. **Read-Only Columns:** Cannot modify column properties - -### TOM API Limitations - -1. **External Dependency:** Requires Analysis Services libraries -2. **Version Compatibility:** May have version dependencies with Excel -3. **File Lock:** TOM connections may lock Excel file -4. **Performance:** Large models may be slow to manipulate - -### Excel Version Requirements - -- **Data Model:** Requires Excel 2013 or later -- **TOM Features:** Best support in Excel 2016+ -- **DAX Improvements:** Excel 2019/Microsoft 365 recommended - ---- - -## Testing Strategy - -### Test Data Requirements - -**Sample Data Model Workbook (`test-datamodel.xlsx`):** - -1. **Tables:** - - Sales (Date, CustomerID, ProductID, Amount, Quantity) - - Customers (ID, Name, Region, Country) - - Products (ID, Name, Category, Price) - -2. **Measures:** - - `Total Sales` := `SUM(Sales[Amount])` - - `Average Sale` := `AVERAGE(Sales[Amount])` - - `Total Customers` := `DISTINCTCOUNT(Sales[CustomerID])` - - `Sales YTD` := `TOTALYTD(SUM(Sales[Amount]), Sales[Date])` - - `Sales % of Total` := `DIVIDE([Total Sales], CALCULATE([Total Sales], ALL(Sales)))` - -3. **Relationships:** - - Sales.CustomerID → Customers.ID (Active) - - Sales.ProductID → Products.ID (Active) - -4. **Formatting:** - - Currency measures: `$#,##0.00` - - Percentage measures: `0.00%` - - Count measures: `#,##0` - -### Test Categories - -1. **Unit Tests:** - - Helper method validation - - Result type serialization - - Error handling - -2. **Integration Tests (Excel COM):** - - List operations with sample model - - Measure view/export operations - - Relationship enumeration - - Refresh operations - -3. **Integration Tests (TOM):** - - Measure create/update/delete - - DAX validation - - Relationship management - - Schema export/import - -4. **Round-Trip Tests:** - - Export measure → Import measure → Verify - - Export schema → Import schema → Verify - - Create relationship → List → Delete → Verify - ---- - -## Success Criteria - -### Phase 1 Success (Basic Operations) - -- [ ] Can list all tables in Data Model -- [ ] Can list all DAX measures with formulas -- [ ] Can view complete measure details -- [ ] Can export measure DAX to file -- [ ] Can list all relationships -- [ ] Can refresh model or specific table -- [ ] All integration tests pass (100%) -- [ ] Documentation complete - -### Phase 3 Success (MCP Integration) - -- [ ] MCP tool `excel_data_model` operational -- [ ] All 6 basic actions working -- [ ] JSON responses properly formatted -- [ ] Error handling comprehensive -- [ ] AI assistant examples documented - -### Phase 4 Success (Advanced TOM) - -- [ ] Can create DAX measures with validation -- [ ] Can update existing measures -- [ ] Can validate DAX syntax -- [ ] Can create/delete relationships -- [ ] Can export/import model schema -- [ ] TOM integration tests pass -- [ ] Advanced documentation complete - ---- - -## Risk Assessment - -| Risk | Likelihood | Impact | Mitigation | -|------|------------|--------|------------| -| Excel version compatibility | Medium | High | Document version requirements, test with Excel 2013+ | -| TOM dependency complexity | High | Medium | Make TOM optional, provide basic operations without it | -| Data Model file corruption | Low | High | Always work on copies, validate before save | -| DAX validation performance | Medium | Medium | Cache validation results, async operations | -| Large model performance | Medium | High | Implement pagination, limit operations | -| Refresh operations hang | Medium | High | Implement timeouts, per-table refresh | - ---- - -## Future Enhancements (Beyond Phase 4) - -### Advanced DAX Features - -1. **DAX Formatter:** Auto-format DAX expressions -2. **DAX Debugger:** Step through DAX calculations -3. **Performance Analyzer:** Identify slow measures -4. **DAX Library:** Pre-built measure templates - -### Model Management - -1. **Hierarchies:** Create drill-down hierarchies -2. **Perspectives:** Filtered model views -3. **Translations:** Multi-language support -4. **Row-Level Security (RLS):** Security role management - -### Integration Features - -1. **Power BI Export:** Deploy model to Power BI -2. **Analysis Services Deploy:** Push to SSAS -3. **Documentation Generation:** Auto-generate BI documentation -4. **Version Control:** Git-friendly model serialization - ---- - -## Related Documentation - -- **Connections Feature Spec:** `specs/CONNECTIONS-FEATURE-SPEC.md` -- **PowerQuery Commands:** `src/ExcelMcp.Core/Commands/PowerQueryCommands.cs` -- **Excel Helper Utilities:** `src/ExcelMcp.Core/ExcelHelper.cs` -- **Microsoft Excel Object Model:** https://docs.microsoft.com/en-us/office/vba/api/overview/excel -- **Tabular Object Model (TOM):** https://docs.microsoft.com/en-us/analysis-services/tom/introduction-to-the-tabular-object-model-tom-in-analysis-services-amo - ---- - -## Conclusion - -This specification provides a **comprehensive roadmap** for adding Data Model and DAX support to ExcelMcp. The **dual-API approach** balances accessibility (Excel COM for basic operations) with power (TOM for advanced features), following established architectural patterns while enabling AI-assisted BI development workflows. - -**Key Differentiator:** Unlike ETL-focused tools, this implementation targets **development and automation** use cases - refactoring DAX, deploying model changes, version control, and AI-assisted optimization. - -**Implementation Priority:** -1. Phase 1 (Basic Operations) - **HIGH** - Provides immediate value with no dependencies -2. Phase 2-3 (CLI/MCP Integration) - **HIGH** - Completes basic feature set -3. Phase 4 (Advanced TOM) - **MEDIUM** - Future enhancement for power users - -This design ensures **incremental value delivery** while maintaining the high quality standards and security-first principles established in the existing ExcelMcp codebase. - ---- - -## Phase 4: TOM API Implementation Status ✅ **COMPLETE** - -### Implementation Summary (October 2025) - -**Status:** Phases 4.1-4.3 completed successfully. All CRUD operations for Data Model now available. - -### Completed Deliverables - -#### Phase 4.1: Core TOM Commands ✅ -- **TomHelper.cs** - Connection management and TOM utilities - - `WithTomServer()` - Resource management pattern for TOM connections - - `ValidateDaxFormula()` - DAX syntax validation - - `FindTable()`, `FindMeasure()`, `FindColumn()`, `FindRelationship()` - Entity lookup - - Multiple connection string format support for Excel compatibility - -- **IDataModelTomCommands.cs** - Interface defining TOM operations - - CreateMeasure, UpdateMeasure - - CreateRelationship, UpdateRelationship - - CreateCalculatedColumn - - ValidateDax - - ImportMeasures (stub for future enhancement) - -- **DataModelTomCommands.cs** - Full implementation - - 6 core methods with comprehensive error handling - - Workflow guidance for LLM interactions - - Security-first design with proper validation - -- **DataModelValidationResult.cs** - New result type for DAX validation - -- **Integration Tests** - 19 test cases covering: - - CreateMeasure (valid, invalid table, duplicate, empty parameters) - - UpdateMeasure (valid, non-existent, no parameters) - - CreateRelationship (valid, invalid table, empty parameters) - - UpdateRelationship (valid, no parameters) - - CreateCalculatedColumn (valid, empty parameters) - - ValidateDax (valid, unbalanced parentheses, empty) - - ImportMeasures (non-existent file, unsupported format) - - File validation tests - -#### Phase 4.2: CLI Integration ✅ -- **IDataModelTomCommands.cs (CLI)** - CLI interface -- **DataModelTomCommands.cs (CLI)** - Rich Spectre.Console implementation - - CreateMeasure - Panel display with formula preview - - UpdateMeasure - Parameter parsing (--formula, --desc, --format) - - CreateRelationship - Relationship configuration (--inactive, --bidirectional) - - UpdateRelationship - Status update controls - - CreateCalculatedColumn - Data type support (--type) - - ValidateDax - Interactive validation with color-coded feedback - -- **Program.cs Updates** - - Added 6 new CLI commands: - - `dm-create-measure` - - `dm-update-measure` - - `dm-create-relationship` - - `dm-update-relationship` - - `dm-create-column` - - `dm-validate-dax` - - Updated help text with TOM command examples - - Integrated with existing CLI architecture - -#### Phase 4.3: MCP Server Integration ✅ -- **ExcelDataModelTool.cs** - Extended existing tool with TOM actions - - Added 6 new actions to datamodel tool - - Parameter schema updated for TOM operations - - Comprehensive validation and error handling - - Workflow guidance for each TOM operation - -- **MCP Actions Implemented:** - - `create-measure` - Create DAX measures with description and format - - `update-measure` - Modify measure formula, description, or format - - `create-relationship` - Define table relationships with cardinality - - `update-relationship` - Modify relationship properties - - `create-column` - Create calculated columns with data types - - `validate-dax` - Validate DAX syntax before creation - -### Technical Highlights - -**TOM API Package:** -- Microsoft.AnalysisServices.NetCore.retail.amd64 v19.84.1 -- Full .NET 10.0 compatibility -- Cross-platform .NET Core support - -**Key Architecture Patterns:** -- Dual-API approach (COM for basic, TOM for advanced) -- Resource management pattern with automatic cleanup -- Security-first with comprehensive validation -- LLM-optimized with workflow guidance -- Test coverage: 19 integration tests - -**Connection Management:** -- Multiple connection string format support -- Automatic database detection -- Proper COM cleanup -- Error handling for connection failures - -### Known Limitations - -1. **TOM Connection Requirements:** - - Requires Excel Data Model (Power Pivot) enabled - - File must have .xlsx or .xlsm format - - Excel version must support Data Model (2013+) - -2. **DAX Validation:** - - Basic syntax checking only - - Full validation occurs during model.SaveChanges() - - Excel's M engine is lenient during import - -3. **Future Enhancements:** - - Batch operations for multiple measures - - JSON import/export for measure definitions - - DAX formatter and beautifier - - Advanced validation with dependency checking - -### Testing Status - -**Test Execution:** -- ✅ All 19 integration tests pass -- ✅ Comprehensive parameter validation -- ✅ Error handling verified -- ✅ Round-trip operations tested -- ⏳ Real Excel Data Model testing pending (requires manual verification) - -**Coverage Areas:** -- Create operations (measures, relationships, columns) -- Update operations (measures, relationships) -- Delete operations (via Phase 1 COM API) -- DAX validation -- Error scenarios -- File validation - -### Usage Examples - -**CLI Example:** -```powershell -# Create a DAX measure -excelcli dm-create-measure Sales.xlsx Sales "Total Sales" "SUM(Sales[Amount])" --format "#,##0.00" - -# Update measure formula -excelcli dm-update-measure Sales.xlsx "Total Sales" --formula "SUM(Sales[Amount]) * 1.1" - -# Create relationship -excelcli dm-create-relationship Sales.xlsx Sales CustomerID Customers CustomerID - -# Validate DAX syntax -excelcli dm-validate-dax Sales.xlsx "SUM(Sales[Amount])" -``` - -**MCP Server Example (via GitHub Copilot):** -``` -User: "Create a Total Sales measure in the Sales table using SUM of Amount column" -Copilot: [Uses datamodel with action=create-measure] - "Measure created successfully. Use dm-read-measure to verify." - -User: "Update the Total Sales measure to include a 10% markup" -Copilot: [Uses datamodel with action=update-measure] - "Measure updated. Formula now includes 10% markup." -``` - -### Documentation Updates - -**Completed:** -- ✅ Phase 4 implementation status documented -- ✅ TOM API architecture documented -- ✅ CLI command reference updated -- ✅ MCP Server action documentation updated -- ✅ Integration test coverage documented - -**Pending:** -- [ ] README.md update with TOM examples -- [ ] Round-trip workflow documentation -- [ ] Performance benchmarks -- [ ] Advanced usage scenarios - -### Next Steps (Phase 4.4) - -1. **Documentation:** - - Update README.md with TOM features - - Create usage examples and tutorials - -2. **Testing:** - - Run integration tests with real Excel files - - Create round-trip workflow tests - - Performance benchmarking - -3. **Future Enhancements:** - - Batch operations API - - JSON import/export for measures - - DAX formatter integration - - Advanced validation with dependency analysis - -### Success Criteria ✅ - -All Phase 4.1-4.3 success criteria met: -- [x] Full CRUD operations work (Create/Update via TOM + Read/Delete via COM) -- [x] 100% test pass rate across all test categories -- [x] MCP Server exposes full CRUD capabilities -- [x] CLI provides complete measure and relationship management -- [x] Error handling comprehensive and user-friendly -- [x] Code quality maintained (zero warnings, zero security issues) - -**Conclusion:** Phase 4 TOM API implementation successfully delivers advanced Data Model CRUD operations while maintaining architectural consistency, security standards, and LLM-optimized workflows established in the ExcelMcp codebase. - ---- - -## Phase 5: DAX EVALUATE Query Execution (RESEARCH COMPLETE) - -### Research Summary (January 2026) - -**Status:** Research complete. Implementation approach validated via diagnostic tests. Ready for implementation. - -**Issue Reference:** [#356 - Add DAX EVALUATE query execution to datamodel tool](https://github.com/sbroenne/mcp-server-excel/issues/356) - -### Background - -Previously, DAX query execution was believed to be blocked due to Excel COM API limitations. CUBEVALUE/CUBEMEMBER worksheet functions fail with "Unable to connect to the server" errors because Excel's INPROC transport for embedded Analysis Services rejects connections from the same process. - -**Research Breakthrough:** Diagnostic tests (Scenarios 14-16 in `DataModelComApiBehaviorTests.cs`) discovered that DAX queries CAN be executed through alternative COM APIs that bypass the CUBEVALUE limitation. - -### Validated Approaches - -#### Approach 1: ADOConnection.Execute (Best for Pure Query Results) - -```csharp -// Get the Data Model's ADO connection -dynamic model = workbook.Model; -dynamic dataModelConn = model.DataModelConnection; -dynamic modelConn = dataModelConn.ModelConnection; -dynamic adoConnection = modelConn.ADOConnection; - -// Execute DAX EVALUATE query -string daxQuery = "EVALUATE TOPN(10, 'Sales', 'Sales'[Amount], DESC)"; -dynamic recordset = adoConnection.Execute(daxQuery); - -// Read results from ADO Recordset -var results = new List<Dictionary<string, object>>(); -while (!recordset.EOF) -{ - var row = new Dictionary<string, object>(); - for (int i = 0; i < recordset.Fields.Count; i++) - { - row[recordset.Fields.Item(i).Name] = recordset.Fields.Item(i).Value; - } - results.Add(row); - recordset.MoveNext(); -} -``` - -**Characteristics:** -- Returns data directly without creating worksheet objects -- Best for read-only query execution -- Results include fully-qualified column names: `TableName[ColumnName]` -- Provider: `MSOLAP.8` (Analysis Services OLE DB) -- Data Source: `$Embedded$` (in-process model) - -#### Approach 2: DAX-Backed Excel Tables (Best for Persistent Results) - -```csharp -// Create model workbook connection -dynamic modelWbConn = model.CreateModelWorkbookConnection("TableName"); -dynamic modelConnection = modelWbConn.ModelConnection; - -// Configure for DAX query -modelConnection.CommandType = 8; // xlCmdDAX -modelConnection.CommandText = "EVALUATE SUMMARIZECOLUMNS(...)"; -modelWbConn.Refresh(); - -// Create Excel Table backed by DAX query -dynamic listObject = sheet.ListObjects.Add( - 4, // xlSrcModel - modelWbConn, - true, // HasHeaders - 1, // xlYes - destRange -); -listObject.Refresh(); -``` - -**Characteristics:** -- Creates persistent Excel Table linked to Data Model -- Table refreshes when Data Model refreshes -- Best for dashboards, reports, analysis sheets -- Data stays synchronized with underlying model - -### Proposed Feature Design - -#### New `datamodel` Action: `evaluate` - -**Purpose:** Execute DAX EVALUATE queries and return results as JSON - -**Parameters:** -- `daxQuery` (required): The DAX EVALUATE expression -- `maxRows` (optional): Maximum rows to return (default: 1000) - -**Example:** -```json -{ - "action": "evaluate", - "sessionId": "abc123", - "daxQuery": "EVALUATE TOPN(100, 'Sales', 'Sales'[Amount], DESC)", - "maxRows": 100 -} -``` - -**Response:** -```json -{ - "success": true, - "columns": ["Sales[Date]", "Sales[Amount]", "Sales[Customer]"], - "rows": [ - {"Sales[Date]": "2024-01-15", "Sales[Amount]": 9999.99, "Sales[Customer]": "Acme Corp"}, - ... - ], - "rowCount": 100, - "truncated": false -} -``` - -#### New `table` Action: `create-from-dax` - -**Purpose:** Create an Excel Table populated by a DAX EVALUATE query - -**Parameters:** -- `daxQuery` (required): The DAX EVALUATE expression -- `tableName` (required): Name for the new Excel Table -- `sheetName` (optional): Target worksheet (default: new sheet) -- `targetCellAddress` (optional): Starting cell (default: A1) - -**Example:** -```json -{ - "action": "create-from-dax", - "sessionId": "abc123", - "daxQuery": "EVALUATE SUMMARIZECOLUMNS('Sales'[Region], \"Total\", SUM('Sales'[Amount]))", - "tableName": "SalesByRegion", - "sheetName": "Summary" -} -``` - -#### New `table` Action: `update-dax` - -**Purpose:** Update the DAX query for an existing DAX-backed Excel Table - -**Parameters:** -- `tableName` (required): Name of the existing DAX-backed table -- `daxQuery` (required): The new DAX EVALUATE expression - -**Example:** -```json -{ - "action": "update-dax", - "sessionId": "abc123", - "tableName": "SalesByRegion", - "daxQuery": "EVALUATE SUMMARIZECOLUMNS('Sales'[Region], 'Sales'[Year], \"Total\", SUM('Sales'[Amount]))" -} -``` - -**Implementation:** -```csharp -// Get the table's underlying connection -dynamic listObject = FindTable(sheet, tableName); -dynamic tableObject = listObject.TableObject; -dynamic workbookConnection = tableObject.WorkbookConnection; -dynamic modelConnection = workbookConnection.ModelConnection; - -// Update the DAX query -modelConnection.CommandText = daxQuery; - -// Refresh to apply the new query -workbookConnection.Refresh(); -listObject.Refresh(); -``` - -#### New `table` Action: `get-dax` - -**Purpose:** Get the DAX query backing an existing DAX-backed Excel Table - -**Parameters:** -- `tableName` (required): Name of the DAX-backed table - -**Example:** -```json -{ - "action": "get-dax", - "sessionId": "abc123", - "tableName": "SalesByRegion" -} -``` - -**Response:** -```json -{ - "success": true, - "tableName": "SalesByRegion", - "daxQuery": "EVALUATE SUMMARIZECOLUMNS('Sales'[Region], \"Total\", SUM('Sales'[Amount]))", - "commandType": "xlCmdDAX", - "lastRefreshed": "2026-01-18T10:30:00Z" -} -``` - -### Key API Constants - -```csharp -// XlListObjectSourceType enumeration -const int xlSrcModel = 4; // PowerPivot Data Model source - -// XlCmdType enumeration -const int xlCmdDAX = 8; // DAX command type (Excel 2013+) -const int xlCmdTable = 3; // Table command type -``` - -### Implementation Notes - -1. **Error Handling:** DAX syntax errors come from MSOLAP provider - parse error messages for user-friendly feedback -2. **Large Results:** Implement pagination or maxRows limit to prevent memory issues -3. **Column Names:** ADO Recordset returns `TableName[ColumnName]` format - consider offering simplified names option -4. **Connection Cleanup:** ADO Recordset and connection must be properly released (COM objects) -5. **Thread Safety:** Use existing batch/session pattern for STA thread management - -### Test Evidence - -- `Scenario14_XlSrcModelDirectListObjectAdd_ExpectToFail` - Proves direct approach fails -- `Scenario15_CreateModelWorkbookConnectionDAXQuery_ExpectSuccess` - Validates ADOConnection approach -- `Scenario16_DaxBackedExcelTable_ExpectSuccess` - Validates ListObjects.Add with DAX - -### Related Documentation - -- [COM-API-BEHAVIOR-FINDINGS.md](../docs/COM-API-BEHAVIOR-FINDINGS.md) - Detailed test findings -- [Issue #356](https://github.com/sbroenne/mcp-server-excel/issues/356) - Feature request - -### Implementation Priority: HIGH - -This feature enables powerful BI workflows: -- Ad-hoc DAX queries for data exploration -- Automated report generation with DAX aggregations -- Creating summary tables from complex Data Model calculations -- AI-assisted data analysis with natural language → DAX → results - diff --git a/specs/FORMATTING-VALIDATION-SPEC.md b/specs/FORMATTING-VALIDATION-SPEC.md deleted file mode 100644 index 2359e6e2..00000000 --- a/specs/FORMATTING-VALIDATION-SPEC.md +++ /dev/null @@ -1,2182 +0,0 @@ -# Excel Formatting & Data Validation Specification - -> **Comprehensive formatting and validation capabilities for ranges, formulas, and tables** -> -> **🤖 Primary Audience:** LLMs using MCP Server tools for professional Excel automation - -## What This Spec Provides (For LLMs) - -This specification consolidates and extends formatting and data validation across all Excel operations: - -### **Number Formatting** - Professional Data Display -- **Currency, percentages, dates, custom formats** - Make data readable -- **Get/Set operations** - Read existing formats or apply new ones -- **Bulk operations** - Format entire ranges or table columns efficiently -- **Common use:** Format sales reports, financial dashboards, date columns, percentages - -### **Visual Formatting** - Professional Appearance -- **Fonts** - Family, size, bold, italic, color, underline, strikethrough -- **Cell appearance** - Background colors, borders, patterns -- **Alignment** - Horizontal, vertical, text wrapping, indentation, rotation -- **Common use:** Headers, highlighting, color-coding, readability improvements - -### **Data Validation** - Data Integrity -- **Dropdown lists** - Restrict input to predefined values -- **Number ranges** - Min/max validation (whole numbers, decimals, dates, times) -- **Text length** - Character limits -- **Custom formulas** - Complex validation rules -- **Error alerts** - Custom messages for invalid data -- **Common use:** Data entry forms, quality control, preventing errors - -### **Why You Need These Tools** -When users ask you to "format the sales report" or "add dropdowns for status," you'll use these commands to: -1. Create professional-looking spreadsheets with proper number formats -2. Apply visual styling (colors, fonts, borders) for readability -3. Ensure data quality with validation rules -4. Build user-friendly data entry interfaces - ---- - -## Current State Analysis - -### ✅ **What EXISTS Today** - -**Range Operations (Phase 1 - Implemented):** -- ✅ Get/Set values and formulas -- ✅ Clear operations (all, contents, formats) -- ✅ Copy operations (all, values, formulas) -- ✅ Insert/delete cells/rows/columns -- ✅ Find/replace -- ✅ Sort -- ✅ Hyperlinks -- ✅ UsedRange, CurrentRegion, RangeInfo - -**Table Operations (Implemented):** -- ✅ Lifecycle (create, rename, delete, info, resize) -- ✅ Style management (SetStyleAsync) -- ✅ Totals row management -- ✅ Filtering (apply, clear, get state) -- ✅ Column operations (add, remove, rename) -- ✅ Sorting (single and multi-column) -- ✅ Structured references -- ✅ Append rows - -**PivotTable Operations (Phase 1 - Implemented):** -- ✅ Lifecycle (create, delete, list, info) -- ✅ Field management (add/remove/move fields in all areas) -- ✅ Field formatting (SetFieldFormatAsync for number formats) -- ✅ Layout management (SetLayoutAsync - Compact, Outline, Tabular) -- ✅ Style management (SetStyleAsync - 28 built-in styles) -- ✅ Data analysis (refresh, filter, sort) - -### ❌ **What's MISSING Today** - -**Number Formatting:** -- ❌ Get number formats from ranges -- ❌ Set number formats (uniform or cell-by-cell) -- ❌ Common format presets (currency, percentage, date patterns) -- ❌ Table column number formatting - -**Visual Formatting:** -- ❌ Font properties (name, size, bold, italic, color, underline, strikethrough) -- ❌ Cell background colors -- ❌ Borders (styles, weights, colors) -- ❌ Alignment (horizontal, vertical, wrap, indent, rotation) -- ❌ Row height / column width -- ❌ Auto-fit columns/rows - -**Data Validation:** -- ❌ Add validation rules to ranges -- ❌ Get validation settings from cells -- ❌ Remove validation -- ❌ All validation types (list, whole, decimal, date, time, text-length, custom) -- ❌ Error alerts and input messages -- ❌ Table column validation - -**Advanced:** -- ❌ Conditional formatting -- ❌ Cell merge/unmerge -- ❌ Cell locking for protection - -**Note:** PivotTable formatting (field formats, layouts, styles) is **fully implemented** and functional. - ---- - -## Target Architecture - -### Design Principles - -1. **LLM-First Design** - Optimized for AI automation workflows -2. **Breaking Changes Acceptable** - Clean API > backwards compatibility -3. **Unified Approach** - Consistent patterns across ranges and tables -4. **Excel COM Native** - Use native Excel capabilities, no custom processing -5. **Performance Optimized** - Batch operations where possible - -### Proposed Command Structure - -``` -Formatting & Validation Commands: -├── RangeCommands (extends existing) -│ ├── Number Formatting (3 methods) -│ ├── Visual Formatting (8 methods) -│ └── Data Validation (4 methods) -│ -└── TableCommands (extends existing) - ├── Number Formatting (2 methods) - ├── Visual Formatting (4 methods - via ranges) - └── Data Validation (2 methods) -``` - -**Philosophy:** -- **RangeCommands** = Low-level, works anywhere -- **TableCommands** = High-level, table-specific convenience methods that delegate to RangeCommands - ---- - -## Proposed API Design - -### 1. Range Number Formatting - -```csharp -public interface IRangeCommands -{ - // === NUMBER FORMAT OPERATIONS === - - /// <summary> - /// Gets number format codes from range (2D array matching range dimensions) - /// Excel COM: Range.NumberFormat - /// </summary> - /// <returns>2D array of format codes (e.g., [["$#,##0.00", "0.00%"], ["m/d/yyyy", "General"]])</returns> - Task<RangeNumberFormatResult> GetNumberFormatsAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - /// <summary> - /// Sets uniform number format for entire range - /// Excel COM: Range.NumberFormat = formatCode - /// </summary> - /// <param name="formatCode"> - /// Excel format code (e.g., "$#,##0.00", "0.00%", "m/d/yyyy", "General", "@") - /// See NumberFormatPresets class for common patterns - /// </param> - Task<OperationResult> SetNumberFormatAsync(IExcelBatch batch, string sheetName, string rangeAddress, string formatCode); - - /// <summary> - /// Sets number formats cell-by-cell from 2D array - /// Excel COM: Range.NumberFormat (per cell) - /// </summary> - /// <param name="formats">2D array of format codes matching range dimensions</param> - Task<OperationResult> SetNumberFormatsAsync(IExcelBatch batch, string sheetName, string rangeAddress, List<List<string>> formats); -} - -/// <summary> -/// Common Excel number format codes for LLM convenience -/// </summary> -public static class NumberFormatPresets -{ - // Currency - public const string Currency = "$#,##0.00"; - public const string CurrencyNoDecimals = "$#,##0"; - public const string CurrencyNegativeRed = "$#,##0.00_);[Red]($#,##0.00)"; - - // Percentages - public const string Percentage = "0.00%"; - public const string PercentageNoDecimals = "0%"; - public const string PercentageOneDecimal = "0.0%"; - - // Dates - public const string DateShort = "m/d/yyyy"; - public const string DateLong = "mmmm d, yyyy"; - public const string DateMonthYear = "mmm yyyy"; - public const string DateDayMonth = "dd/mm/yyyy"; - - // Times - public const string Time12Hour = "h:mm AM/PM"; - public const string Time24Hour = "h:mm"; - public const string DateTime = "m/d/yyyy h:mm"; - - // Numbers - public const string Number = "#,##0.00"; - public const string NumberNoDecimals = "#,##0"; - public const string NumberOneDecimal = "#,##0.0"; - public const string Scientific = "0.00E+00"; - - // Special - public const string Text = "@"; - public const string Fraction = "# ?/?"; - public const string Accounting = "_($* #,##0.00_);_($* (#,##0.00);_($* \"-\"??_);_(@_)"; - public const string General = "General"; -} - -// Result type -public class RangeNumberFormatResult : ResultBase -{ - public string SheetName { get; set; } = string.Empty; - public string RangeAddress { get; set; } = string.Empty; - - /// <summary> - /// 2D array of number format codes (matches range dimensions) - /// </summary> - public List<List<string>> Formats { get; set; } = []; - - public int RowCount { get; set; } - public int ColumnCount { get; set; } -} -``` - ---- - -### 2. Range Visual Formatting - -```csharp -public interface IRangeCommands -{ - // === FONT OPERATIONS === - - /// <summary> - /// Gets font properties from first cell in range - /// Excel COM: Range.Font - /// </summary> - Task<RangeFontResult> GetFontAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - /// <summary> - /// Sets font properties for entire range - /// Excel COM: Range.Font - /// </summary> - /// <param name="font">Font properties (null values = no change)</param> - Task<OperationResult> SetFontAsync(IExcelBatch batch, string sheetName, string rangeAddress, FontOptions font); - - // === CELL APPEARANCE === - - /// <summary> - /// Gets background color from first cell in range - /// Excel COM: Range.Interior.Color - /// </summary> - Task<RangeColorResult> GetBackgroundColorAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - /// <summary> - /// Sets background color for entire range - /// Excel COM: Range.Interior.Color - /// </summary> - /// <param name="color">RGB color as integer: (red) | (green << 8) | (blue << 16)</param> - Task<OperationResult> SetBackgroundColorAsync(IExcelBatch batch, string sheetName, string rangeAddress, int color); - - /// <summary> - /// Clears background color (resets to no fill) - /// Excel COM: Range.Interior.ColorIndex = xlColorIndexNone - /// </summary> - Task<OperationResult> ClearBackgroundColorAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - /// <summary> - /// Gets border settings from range - /// Excel COM: Range.Borders - /// </summary> - Task<RangeBorderResult> GetBordersAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - /// <summary> - /// Sets borders for range - /// Excel COM: Range.Borders - /// </summary> - Task<OperationResult> SetBordersAsync(IExcelBatch batch, string sheetName, string rangeAddress, BorderOptions borders); - - /// <summary> - /// Clears all borders from range - /// Excel COM: Range.Borders.LineStyle = xlLineStyleNone - /// </summary> - Task<OperationResult> ClearBordersAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - // === ALIGNMENT === - - /// <summary> - /// Gets alignment properties from first cell in range - /// Excel COM: Range.HorizontalAlignment, Range.VerticalAlignment, etc. - /// </summary> - Task<RangeAlignmentResult> GetAlignmentAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - /// <summary> - /// Sets alignment properties for range - /// Excel COM: Range alignment properties - /// </summary> - Task<OperationResult> SetAlignmentAsync(IExcelBatch batch, string sheetName, string rangeAddress, AlignmentOptions alignment); - - // === ROW HEIGHT / COLUMN WIDTH === - - /// <summary> - /// Auto-fits column widths to content - /// Excel COM: Range.Columns.AutoFit() - /// </summary> - Task<OperationResult> AutoFitColumnsAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - /// <summary> - /// Auto-fits row heights to content - /// Excel COM: Range.Rows.AutoFit() - /// </summary> - Task<OperationResult> AutoFitRowsAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - /// <summary> - /// Sets column width in points - /// Excel COM: Range.ColumnWidth - /// </summary> - Task<OperationResult> SetColumnWidthAsync(IExcelBatch batch, string sheetName, string rangeAddress, double width); - - /// <summary> - /// Sets row height in points - /// Excel COM: Range.RowHeight - /// </summary> - Task<OperationResult> SetRowHeightAsync(IExcelBatch batch, string sheetName, string rangeAddress, double height); -} - -// Supporting types -public class FontOptions -{ - public string? Name { get; set; } // Font family (e.g., "Arial", "Calibri") - public int? Size { get; set; } // Font size in points - public bool? Bold { get; set; } // Bold text - public bool? Italic { get; set; } // Italic text - public int? Color { get; set; } // RGB color - public bool? Underline { get; set; } // Underline - public bool? Strikethrough { get; set; } // Strikethrough -} - -public class BorderOptions -{ - public BorderStyle Style { get; set; } = BorderStyle.Continuous; - public BorderWeight Weight { get; set; } = BorderWeight.Thin; - public int? Color { get; set; } // RGB color (null = default black) - public bool ApplyToAll { get; set; } = true; // Apply to all edges (top, bottom, left, right) - - // Individual edge control (if ApplyToAll = false) - public bool? Top { get; set; } - public bool? Bottom { get; set; } - public bool? Left { get; set; } - public bool? Right { get; set; } -} - -public enum BorderStyle -{ - None, // xlLineStyleNone - Continuous, // xlContinuous - Dashed, // xlDash - Dotted, // xlDot - DashDot, // xlDashDot - DashDotDot, // xlDashDotDot - Double // xlDouble -} - -public enum BorderWeight -{ - Hairline, // xlHairline - Thin, // xlThin - Medium, // xlMedium - Thick // xlThick -} - -public class AlignmentOptions -{ - public HorizontalAlignment? Horizontal { get; set; } - public VerticalAlignment? Vertical { get; set; } - public bool? WrapText { get; set; } // Text wrapping - public int? Indent { get; set; } // Indentation level (0-15) - public int? Orientation { get; set; } // Text rotation in degrees (-90 to 90) -} - -public enum HorizontalAlignment -{ - General, // xlGeneral (Excel default) - Left, // xlLeft - Center, // xlCenter - Right, // xlRight - Fill, // xlFill - Justify, // xlJustify - CenterAcrossSelection, // xlCenterAcrossSelection - Distributed // xlDistributed -} - -public enum VerticalAlignment -{ - Top, // xlTop - Center, // xlCenter - Bottom, // xlBottom - Justify, // xlJustify - Distributed // xlDistributed -} - -// Result types -public class RangeFontResult : ResultBase -{ - public string SheetName { get; set; } = string.Empty; - public string RangeAddress { get; set; } = string.Empty; - public string FontName { get; set; } = string.Empty; - public int FontSize { get; set; } - public bool Bold { get; set; } - public bool Italic { get; set; } - public int Color { get; set; } - public bool Underline { get; set; } - public bool Strikethrough { get; set; } -} - -public class RangeColorResult : ResultBase -{ - public string SheetName { get; set; } = string.Empty; - public string RangeAddress { get; set; } = string.Empty; - public bool HasColor { get; set; } - public int? Color { get; set; } // RGB integer (null if no color) - public int? Red { get; set; } // Red component 0-255 - public int? Green { get; set; } // Green component 0-255 - public int? Blue { get; set; } // Blue component 0-255 - public string? HexColor { get; set; } // #RRGGBB format -} - -public class RangeBorderResult : ResultBase -{ - public string SheetName { get; set; } = string.Empty; - public string RangeAddress { get; set; } = string.Empty; - public bool HasBorders { get; set; } - public string? Style { get; set; } - public string? Weight { get; set; } - public int? Color { get; set; } -} - -public class RangeAlignmentResult : ResultBase -{ - public string SheetName { get; set; } = string.Empty; - public string RangeAddress { get; set; } = string.Empty; - public string Horizontal { get; set; } = string.Empty; - public string Vertical { get; set; } = string.Empty; - public bool WrapText { get; set; } - public int Indent { get; set; } - public int Orientation { get; set; } -} -``` - ---- - -### 3. Range Data Validation - -```csharp -public interface IRangeCommands -{ - // === DATA VALIDATION OPERATIONS === - - /// <summary> - /// Gets data validation settings from first cell in range - /// Excel COM: Range.Validation - /// </summary> - Task<RangeValidationResult> GetValidationAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - /// <summary> - /// Adds data validation to range - /// Excel COM: Range.Validation.Add() - /// </summary> - /// <param name="validation">Validation rule configuration</param> - Task<OperationResult> AddValidationAsync(IExcelBatch batch, string sheetName, string rangeAddress, ValidationRule validation); - - /// <summary> - /// Modifies existing validation rule - /// Excel COM: Range.Validation.Modify() - /// </summary> - Task<OperationResult> ModifyValidationAsync(IExcelBatch batch, string sheetName, string rangeAddress, ValidationRule validation); - - /// <summary> - /// Removes data validation from range - /// Excel COM: Range.Validation.Delete() - /// </summary> - Task<OperationResult> RemoveValidationAsync(IExcelBatch batch, string sheetName, string rangeAddress); -} - -// Supporting types -public class ValidationRule -{ - /// <summary> - /// Type of validation (List, WholeNumber, Decimal, Date, Time, TextLength, Custom) - /// </summary> - public ValidationType Type { get; set; } - - /// <summary> - /// Comparison operator (Between, NotBetween, Equal, NotEqual, Greater, Less, GreaterOrEqual, LessOrEqual) - /// Only used for numeric/date validations (not for List or Custom) - /// </summary> - public ValidationOperator Operator { get; set; } = ValidationOperator.Between; - - /// <summary> - /// First formula/value - /// - List: Comma-separated values "Item1,Item2,Item3" or range reference "=$A$1:$A$10" - /// - Number/Date/Time: Minimum value or single comparison value - /// - TextLength: Minimum length - /// - Custom: Formula expression (must return TRUE/FALSE) - /// </summary> - public string Formula1 { get; set; } = string.Empty; - - /// <summary> - /// Second formula/value (only for Between/NotBetween operators) - /// - Number/Date/Time: Maximum value - /// - TextLength: Maximum length - /// </summary> - public string? Formula2 { get; set; } - - /// <summary> - /// Whether to ignore blank cells (default: true) - /// </summary> - public bool IgnoreBlank { get; set; } = true; - - /// <summary> - /// Whether to show input message when cell is selected - /// </summary> - public bool ShowInputMessage { get; set; } = false; - - /// <summary> - /// Input message title - /// </summary> - public string? InputTitle { get; set; } - - /// <summary> - /// Input message content - /// </summary> - public string? InputMessage { get; set; } - - /// <summary> - /// Whether to show error alert on invalid data - /// </summary> - public bool ShowErrorAlert { get; set; } = true; - - /// <summary> - /// Error alert style (Stop, Warning, Information) - /// </summary> - public ValidationAlertStyle ErrorStyle { get; set; } = ValidationAlertStyle.Stop; - - /// <summary> - /// Error alert title - /// </summary> - public string? ErrorTitle { get; set; } - - /// <summary> - /// Error alert message - /// </summary> - public string? ErrorMessage { get; set; } -} - -public enum ValidationType -{ - List, // xlValidateList - dropdown list - WholeNumber, // xlValidateWholeNumber - Decimal, // xlValidateDecimal - Date, // xlValidateDate - Time, // xlValidateTime - TextLength, // xlValidateTextLength - Custom // xlValidateCustom - formula-based -} - -public enum ValidationOperator -{ - Between, // xlBetween - NotBetween, // xlNotBetween - Equal, // xlEqual - NotEqual, // xlNotEqual - Greater, // xlGreater - Less, // xlLess - GreaterOrEqual, // xlGreaterEqual - LessOrEqual // xlLessEqual -} - -public enum ValidationAlertStyle -{ - Stop, // xlValidAlertStop - prevents invalid data - Warning, // xlValidAlertWarning - warns but allows - Information // xlValidAlertInformation - info only -} - -public class RangeValidationResult : ResultBase -{ - public string SheetName { get; set; } = string.Empty; - public string RangeAddress { get; set; } = string.Empty; - public bool HasValidation { get; set; } - public string? Type { get; set; } - public string? Operator { get; set; } - public string? Formula1 { get; set; } - public string? Formula2 { get; set; } - public bool IgnoreBlank { get; set; } - public bool ShowInputMessage { get; set; } - public string? InputTitle { get; set; } - public string? InputMessage { get; set; } - public bool ShowErrorAlert { get; set; } - public string? ErrorStyle { get; set; } - public string? ErrorTitle { get; set; } - public string? ErrorMessage { get; set; } -} -``` - ---- - -### 4. Table Formatting & Validation - -```csharp -public interface ITableCommands -{ - // === NUMBER FORMATTING (delegates to RangeCommands) === - - /// <summary> - /// Gets number formats for a table column - /// Delegates to RangeCommands.GetNumberFormatsAsync() on column range - /// </summary> - Task<RangeNumberFormatResult> GetColumnNumberFormatAsync(IExcelBatch batch, string tableName, string columnName); - - /// <summary> - /// Sets uniform number format for entire table column - /// Delegates to RangeCommands.SetNumberFormatAsync() on column data range (excludes header) - /// </summary> - /// <param name="formatCode">Excel format code (e.g., "$#,##0.00", "0.00%")</param> - Task<OperationResult> SetColumnNumberFormatAsync(IExcelBatch batch, string tableName, string columnName, string formatCode); - - // === VISUAL FORMATTING (delegates to RangeCommands) === - - /// <summary> - /// Sets font for table column data cells - /// Delegates to RangeCommands.SetFontAsync() on column data range - /// </summary> - Task<OperationResult> SetColumnFontAsync(IExcelBatch batch, string tableName, string columnName, FontOptions font); - - /// <summary> - /// Sets background color for table column data cells - /// Delegates to RangeCommands.SetBackgroundColorAsync() - /// </summary> - Task<OperationResult> SetColumnBackgroundColorAsync(IExcelBatch batch, string tableName, string columnName, int color); - - /// <summary> - /// Sets alignment for table column data cells - /// Delegates to RangeCommands.SetAlignmentAsync() - /// </summary> - Task<OperationResult> SetColumnAlignmentAsync(IExcelBatch batch, string tableName, string columnName, AlignmentOptions alignment); - - /// <summary> - /// Auto-fits table column width to content - /// Delegates to RangeCommands.AutoFitColumnsAsync() - /// </summary> - Task<OperationResult> AutoFitColumnAsync(IExcelBatch batch, string tableName, string columnName); - - // === DATA VALIDATION (delegates to RangeCommands) === - - /// <summary> - /// Adds data validation to table column data cells - /// Delegates to RangeCommands.AddValidationAsync() on column data range - /// </summary> - Task<OperationResult> AddColumnValidationAsync(IExcelBatch batch, string tableName, string columnName, ValidationRule validation); - - /// <summary> - /// Removes data validation from table column - /// Delegates to RangeCommands.RemoveValidationAsync() - /// </summary> - Task<OperationResult> RemoveColumnValidationAsync(IExcelBatch batch, string tableName, string columnName); -} -``` - ---- - -### 5. PivotTable Formatting - -**Note:** PivotTable formatting is **ALREADY IMPLEMENTED** in Phase 1 (as of October 30, 2025). See [PIVOTTABLE-API-SPECIFICATION.md](PIVOTTABLE-API-SPECIFICATION.md) for complete details. - -```csharp -public interface IPivotTableCommands -{ - // === NUMBER FORMATTING (IMPLEMENTED) === - - /// <summary> - /// Sets number format for value field in PivotTable - /// Excel COM: PivotField.NumberFormat - /// </summary> - /// <param name="fieldName">Name of value field (e.g., "Sum of Sales")</param> - /// <param name="numberFormat">Excel format code (e.g., "$#,##0.00", "0.00%")</param> - Task<PivotFieldResult> SetFieldFormatAsync(IExcelBatch batch, string pivotTableName, - string fieldName, string numberFormat); - - // === LAYOUT & STYLE (IMPLEMENTED) === - - /// <summary> - /// Sets PivotTable layout form - /// Excel COM: PivotTable.RowAxisLayout() or PivotTable.LayoutForm - /// </summary> - /// <param name="layout">Compact, Outline, or Tabular</param> - Task<OperationResult> SetLayoutAsync(IExcelBatch batch, string pivotTableName, - PivotTableLayout layout); - - /// <summary> - /// Sets PivotTable visual style - /// Excel COM: PivotTable.TableStyle2 - /// </summary> - /// <param name="styleName"> - /// Built-in style name (e.g., "PivotStyleMedium2", "PivotStyleLight16") - /// See PivotTableStylePresets for common options - /// </param> - Task<OperationResult> SetStyleAsync(IExcelBatch batch, string pivotTableName, - string styleName); -} - -// Layout options -public enum PivotTableLayout -{ - Compact, // xlCompactForm - hierarchical, space-saving - Outline, // xlOutlineForm - hierarchical with subtotals - Tabular // xlTabularForm - flat, traditional layout -} - -/// <summary> -/// Common PivotTable style names for LLM convenience -/// </summary> -public static class PivotTableStylePresets -{ - // Light Styles (subtle colors) - public const string Light1 = "PivotStyleLight1"; - public const string Light2 = "PivotStyleLight2"; - public const string Light9 = "PivotStyleLight9"; - public const string Light16 = "PivotStyleLight16"; - public const string Light21 = "PivotStyleLight21"; - - // Medium Styles (balanced colors) - public const string Medium1 = "PivotStyleMedium1"; - public const string Medium2 = "PivotStyleMedium2"; // Popular choice - public const string Medium3 = "PivotStyleMedium3"; - public const string Medium9 = "PivotStyleMedium9"; - public const string Medium10 = "PivotStyleMedium10"; - - // Dark Styles (high contrast) - public const string Dark1 = "PivotStyleDark1"; - public const string Dark2 = "PivotStyleDark2"; - public const string Dark11 = "PivotStyleDark11"; - - // None (remove styling) - public const string None = ""; -} -``` - -**PivotTable Formatting Capabilities (Implemented):** - -✅ **Number Formatting** - Format value fields (Sum of Sales, Average Price, etc.) - **PERSISTENT (survives refresh)** -✅ **Layout Forms** - Compact, Outline, or Tabular layout - **PERSISTENT** -✅ **Visual Styles** - 28 built-in PivotTable styles (Light, Medium, Dark) - **PERSISTENT** - -**How It Works:** -```csharp -// SetFieldFormatAsync() sets format on the PivotField object itself -// Excel COM: dataField.NumberFormat = "$#,##0.00" -// This is a FIELD-LEVEL setting, not a cell-level format -// Result: Format persists across RefreshTable(), save/reopen, data updates -``` - -**Advanced Formatting (NOT in current scope):** -- ❌ Row/column header cell formatting (use RangeCommands, but lost on refresh) -- ❌ Grand total cell formatting (use RangeCommands, but lost on refresh) -- ❌ Conditional formatting within PivotTable (use RangeCommands, but lost on refresh) -- ❌ Individual data cell formatting (recalculated on refresh, formats lost) - -**Recommended Approach for PivotTable Formatting:** - -1. ✅ **Use PivotTableCommands** for structure and value field formats (PERSISTENT) - - `SetFieldFormatAsync()` for number formats on value fields - - `SetLayoutAsync()` for layout form - - `SetStyleAsync()` for visual appearance - -2. ⚠️ **Use RangeCommands carefully** for additional formatting (NOT PERSISTENT) - - Can format header cells, grand totals, or specific data cells - - **Warning:** These formats will be **lost on refresh** - - Only use for one-time formatting or static PivotTables that won't refresh - -3. 💡 **Best Practice:** Always use field-level formats when possible - - Field formats are part of the PivotTable definition - - They persist across all PivotTable operations - - They're saved with the workbook - ---- - -## MCP Server Integration - -### Updated range Tool - -```json -{ - "name": "range", - "actions": [ - // EXISTING (Phase 1) - "get-values", "set-values", - "get-formulas", "set-formulas", - "clear-all", "clear-contents", "clear-formats", - "copy", "copy-values", "copy-formulas", - "insert-cells", "delete-cells", "insert-rows", "delete-rows", "insert-columns", "delete-columns", - "find", "replace", "sort", - "get-used-range", "get-current-region", "get-range-info", - "add-hyperlink", "remove-hyperlink", "list-hyperlinks", "get-hyperlink", - - // NEW: NUMBER FORMATTING - "get-number-formats", - "set-number-format", - "set-number-formats", - - // NEW: VISUAL FORMATTING - "get-font", "set-font", - "get-background-color", "set-background-color", "clear-background-color", - "get-borders", "set-borders", "clear-borders", - "get-alignment", "set-alignment", - "auto-fit-columns", "auto-fit-rows", - "set-column-width", "set-row-height", - - // NEW: DATA VALIDATION - "get-validation", - "add-validation", - "modify-validation", - "remove-validation" - ] -} -``` - -### Updated table Tool - -```json -{ - "name": "table", - "actions": [ - // EXISTING - "list", "create", "rename", "delete", "info", "resize", - "toggle-totals", "set-column-total", "append-rows", "set-style", - "add-to-datamodel", - "apply-filter", "apply-filter-values", "clear-filters", "get-filters", - "add-column", "remove-column", "rename-column", - "get-structured-reference", - "sort", "sort-multi", - - // NEW: FORMATTING & VALIDATION - "get-column-number-format", "set-column-number-format", - "set-column-font", "set-column-background-color", "set-column-alignment", "auto-fit-column", - "add-column-validation", "remove-column-validation" - ] -} -``` - -### pivottable Tool (Existing - Already Implemented) - -```json -{ - "name": "pivottable", - "description": "PivotTable operations - create, configure, format, and analyze data", - "actions": [ - // EXISTING (18 actions implemented in Phase 1) - "create", "delete", "list", "info", - "list-fields", "add-row-field", "add-column-field", "add-value-field", - "add-filter-field", "remove-field", "move-field", - "set-field-function", "set-field-name", "set-field-format", - "get-data", "set-field-filter", "sort-field", "refresh", - - // FORMATTING (IMPLEMENTED) - "set-layout", // Compact, Outline, or Tabular - "set-style" // PivotTable visual styles - ] -} -``` - -**Note:** PivotTable formatting commands (`set-layout`, `set-style`, `set-field-format`) are **already implemented** and functional. See [PIVOTTABLE-API-SPECIFICATION.md](PIVOTTABLE-API-SPECIFICATION.md) for complete documentation. - ---- - -## MCP Server Parameter Reference (Critical for LLMs) - -### Common Parameters Across All Actions - -**Required on ALL actions:** -- `excelPath` (string) - Absolute path to Excel file (e.g., "C:\\Users\\user\\sales.xlsx") -- `action` (string) - Action to perform (e.g., "set-number-format", "add-validation") - -**Optional batch parameter:** -- `batchId` (string) - If using batch mode, reference batch session ID - -### range Actions - Parameter Details - -#### Number Formatting - -**get-number-formats** -- Required: `excelPath`, `sheetName`, `rangeAddress` -- Returns: `{ success, sheetName, rangeAddress, formats: [[string]], rowCount, columnCount }` - -**set-number-format** -- Required: `excelPath`, `sheetName`, `rangeAddress`, `formatCode` -- `formatCode`: Excel format string (e.g., "$#,##0.00", "0.00%", "m/d/yyyy", "@") -- Returns: `{ success, message }` - -**set-number-formats** -- Required: `excelPath`, `sheetName`, `rangeAddress`, `formats` -- `formats`: 2D array of format codes `[["$#,##0", "0.00%"], ["m/d/yyyy", "General"]]` -- Returns: `{ success, message }` - -#### Visual Formatting - -**set-font** -- Required: `excelPath`, `sheetName`, `rangeAddress`, `font` -- `font` (object with ALL properties optional): - - `name` (string): Font family (e.g., "Arial", "Calibri", "Times New Roman") - - `size` (number): Font size in points (e.g., 10, 12, 14) - - `bold` (boolean): Bold text (true/false) - - `italic` (boolean): Italic text (true/false) - - `color` (number): RGB integer (see RGB calculation below) - - `underline` (boolean): Underline text (true/false) - - `strikethrough` (boolean): Strikethrough text (true/false) -- Returns: `{ success, message }` - -**set-background-color** -- Required: `excelPath`, `sheetName`, `rangeAddress`, `color` -- `color` (number): RGB integer (see RGB calculation below) -- Returns: `{ success, message }` - -**set-borders** -- Required: `excelPath`, `sheetName`, `rangeAddress`, `borders` -- `borders` (object): - - `style` (string): "Continuous", "Dashed", "Dotted", "DashDot", "DashDotDot", "Double", "None" - - `weight` (string): "Hairline", "Thin", "Medium", "Thick" - - `color` (number): RGB integer (optional, default black) - - `applyToAll` (boolean): Apply to all edges (true) or specify individual edges (false) - - If `applyToAll` = false: - - `top` (boolean): Apply to top edge - - `bottom` (boolean): Apply to bottom edge - - `left` (boolean): Apply to left edge - - `right` (boolean): Apply to right edge -- Returns: `{ success, message }` - -**set-alignment** -- Required: `excelPath`, `sheetName`, `rangeAddress`, `alignment` -- `alignment` (object with ALL properties optional): - - `horizontal` (string): "General", "Left", "Center", "Right", "Fill", "Justify", "CenterAcrossSelection", "Distributed" - - `vertical` (string): "Top", "Center", "Bottom", "Justify", "Distributed" - - `wrapText` (boolean): Enable text wrapping (true/false) - - `indent` (number): Indentation level 0-15 - - `orientation` (number): Text rotation in degrees (-90 to 90) -- Returns: `{ success, message }` - -#### Data Validation - -**add-validation** -- Required: `excelPath`, `sheetName`, `rangeAddress`, `validation` -- `validation` (object): - - `type` (string): "List", "WholeNumber", "Decimal", "Date", "Time", "TextLength", "Custom" - - `operator` (string): "Between", "NotBetween", "Equal", "NotEqual", "Greater", "Less", "GreaterOrEqual", "LessOrEqual" - - `formula1` (string): First value/formula/list - - `formula2` (string, optional): Second value (for Between/NotBetween) - - `ignoreBlank` (boolean, optional): Ignore blank cells (default: true) - - `showInputMessage` (boolean, optional): Show input message (default: false) - - `inputTitle` (string, optional): Input message title - - `inputMessage` (string, optional): Input message text - - `showErrorAlert` (boolean, optional): Show error alert (default: true) - - `errorStyle` (string, optional): "Stop", "Warning", "Information" (default: "Stop") - - `errorTitle` (string, optional): Error alert title - - `errorMessage` (string, optional): Error alert message -- Returns: `{ success, message }` - -### table Actions - Parameter Details - -**set-column-number-format** -- Required: `excelPath`, `tableName`, `columnName`, `formatCode` -- Returns: `{ success, message }` - -**set-column-font** -- Required: `excelPath`, `tableName`, `columnName`, `font` -- `font`: Same structure as range set-font -- Returns: `{ success, message }` - -**set-column-background-color** -- Required: `excelPath`, `tableName`, `columnName`, `color` -- Returns: `{ success, message }` - -**add-column-validation** -- Required: `excelPath`, `tableName`, `columnName`, `validation` -- `validation`: Same structure as range add-validation -- Returns: `{ success, message }` - -### pivottable Actions - Parameter Details - -**set-field-format** -- Required: `excelPath`, `pivotTableName`, `fieldName`, `numberFormat` -- `fieldName`: Use value field name from list-fields (e.g., "Sum of Sales", "Average of Price") -- Returns: `{ success, fieldName, customName, area, numberFormat }` - -**set-layout** -- Required: `excelPath`, `pivotTableName`, `layout` -- `layout` (string): "Compact", "Outline", "Tabular" -- Returns: `{ success, message }` - -**set-style** -- Required: `excelPath`, `pivotTableName`, `styleName` -- `styleName` (string): Built-in style name (e.g., "PivotStyleMedium2", "PivotStyleLight16") -- Returns: `{ success, message }` - ---- - -## RGB Color Calculation (Critical Reference) - -**How to Calculate RGB Integer for Color Parameters:** - -``` -Formula: RGB(red, green, blue) = red + (green × 256) + (blue × 256²) -Alternative: red + (green << 8) + (blue << 16) - -Where: red, green, blue are each 0-255 -``` - -**Common Colors (Ready to Use):** - -| Color Name | RGB Values | Integer Value | Use For | -|------------|------------|---------------|---------| -| **Red** | (255, 0, 0) | 255 | Errors, alerts, negative values | -| **Green** | (0, 255, 0) | 65280 | Success, positive values | -| **Blue** | (0, 0, 255) | 16711680 | Headers, links | -| **Yellow** | (255, 255, 0) | 65535 | Highlights, warnings | -| **Orange** | (255, 165, 0) | 42495 | Warnings, important items | -| **Purple** | (128, 0, 128) | 8388736 | Categories, special items | -| **Light Gray** | (211, 211, 211) | 13882323 | Disabled, secondary | -| **Light Blue** | (173, 216, 230) | 15128749 | Header backgrounds | -| **Light Green** | (144, 238, 144) | 9498256 | Positive highlights | -| **Light Yellow** | (255, 255, 224) | 14745599 | Subtle highlights | -| **White** | (255, 255, 255) | 16777215 | Clear/reset background | -| **Black** | (0, 0, 0) | 0 | Text, borders | - -**When User Says Color Name:** -``` -"red background" → color: 255 -"yellow highlight" → color: 65535 -"green text" → font.color: 65280 -"light blue cells" → color: 15128749 -``` - ---- - -## Batch Mode Usage (Performance Optimization) - -**When to Use Batch Mode:** -- Formatting 3+ ranges/columns -- Multiple operations on same file -- Complete workbook setup workflows - -**How to Use Batch Mode with MCP Server:** - -```json -// Step 1: Begin batch session -{ - "tool": "begin_excel_batch", - "excelPath": "C:\\reports\\sales.xlsx" -} -// Returns: { "success": true, "batchId": "batch_abc123" } - -// Step 2: Execute multiple operations (use batchId) -{ - "tool": "range", - "action": "set-number-format", - "batchId": "batch_abc123", - "sheetName": "Sales", - "rangeAddress": "D2:D100", - "formatCode": "$#,##0.00" -} - -{ - "tool": "range", - "action": "set-font", - "batchId": "batch_abc123", - "sheetName": "Sales", - "rangeAddress": "A1:E1", - "font": { "bold": true, "size": 12 } -} - -{ - "tool": "range", - "action": "add-validation", - "batchId": "batch_abc123", - "sheetName": "Sales", - "rangeAddress": "F2:F100", - "validation": { - "type": "List", - "formula1": "Active,Inactive" - } -} - -// Step 3: Commit batch (saves all changes) -{ - "tool": "commit_excel_batch", - "batchId": "batch_abc123", - "save": true -} -// Returns: { "success": true, "message": "Batch committed, changes saved" } -``` - -**Benefits:** -- ✅ Excel opened once for all operations -- ✅ Changes saved once at end -- ✅ 5-10x faster for multiple operations -- ✅ Atomic - all succeed or all fail - ---- - -## Common Mistakes & How to Avoid Them - -### Range Formatting Mistakes - -**❌ Mistake 1: Wrong range address separator** -```json -// WRONG -"rangeAddress": "A1-D10" - -// CORRECT -"rangeAddress": "A1:D10" -``` - -**❌ Mistake 2: Wrong alignment case** -```json -// WRONG -"alignment": { "horizontal": "center" } - -// CORRECT -"alignment": { "horizontal": "Center" } // Capitalize enum values -``` - -**❌ Mistake 3: Formatting entire columns inefficiently** -```json -// SLOW: Formats millions of cells -"rangeAddress": "A:Z" - -// FAST: Format only used range -"rangeAddress": "A1:Z1000" -// Or use get-used-range first to find actual data -``` - -**❌ Mistake 4: Wrong RGB color calculation** -```json -// WRONG: Using separate R, G, B values -"color": { "red": 255, "green": 0, "blue": 0 } - -// CORRECT: Use integer -"color": 255 // For red -``` - -**❌ Mistake 5: Providing partial 2D array for set-number-formats** -```json -// WRONG: 2x3 range but only 1x2 formats array -"rangeAddress": "A1:C2", -"formats": [["$#,##0", "0.00%"]] - -// CORRECT: Match dimensions -"formats": [ - ["$#,##0", "0.00%", "m/d/yyyy"], - ["$#,##0", "0.00%", "m/d/yyyy"] -] -``` - -### Validation Mistakes - -**❌ Mistake 6: Wrong validation type for dropdown** -```json -// WRONG -"type": "Dropdown" - -// CORRECT -"type": "List" -``` - -**❌ Mistake 7: Forgetting formula2 for Between operator** -```json -// WRONG: Between requires two values -"type": "WholeNumber", -"operator": "Between", -"formula1": "1" -// Missing formula2! - -// CORRECT -"type": "WholeNumber", -"operator": "Between", -"formula1": "1", -"formula2": "100" -``` - -**❌ Mistake 8: Using formula reference without = prefix** -```json -// WRONG: List from range needs = prefix -"formula1": "A1:A10" - -// CORRECT -"formula1": "=$A$1:$A$10" -``` - -### Table Formatting Mistakes - -**❌ Mistake 9: Using range address instead of table name** -```json -// WRONG: table needs table name -"tableName": "A1:D100" - -// CORRECT -"tableName": "SalesData" -``` - -**❌ Mistake 10: Formatting table headers (not supported)** -```json -// WRONG: Headers have fixed formatting from table style -{ - "tool": "table", - "action": "set-column-font", - "columnName": "Amount", - "font": { "bold": true } // Affects data cells only, not header -} - -// CORRECT: Use table styles instead -{ - "tool": "table", - "action": "set-style", - "tableName": "SalesData", - "styleName": "TableStyleMedium2" -} -``` - ---- - -## LLM Usage Examples - -### Example 1: Format Sales Report - -```json -// Scenario: LLM formatting a professional sales report - -// Step 1: Format currency column -{ - "tool": "range", - "action": "set-number-format", - "sheetName": "Sales", - "rangeAddress": "D2:D100", - "formatCode": "$#,##0.00" -} - -// Step 2: Format percentage column -{ - "tool": "range", - "action": "set-number-format", - "sheetName": "Sales", - "rangeAddress": "E2:E100", - "formatCode": "0.00%" -} - -// Step 3: Format date column -{ - "tool": "range", - "action": "set-number-format", - "sheetName": "Sales", - "rangeAddress": "A2:A100", - "formatCode": "m/d/yyyy" -} - -// Step 4: Bold headers -{ - "tool": "range", - "action": "set-font", - "sheetName": "Sales", - "rangeAddress": "A1:E1", - "font": { "bold": true, "size": 12 } -} - -// Step 5: Center align headers -{ - "tool": "range", - "action": "set-alignment", - "sheetName": "Sales", - "rangeAddress": "A1:E1", - "alignment": { "horizontal": "Center" } -} - -// Step 6: Add borders -{ - "tool": "range", - "action": "set-borders", - "sheetName": "Sales", - "rangeAddress": "A1:E100", - "borders": { "style": "Continuous", "weight": "Thin" } -} - -// Step 7: Auto-fit columns -{ - "tool": "range", - "action": "auto-fit-columns", - "sheetName": "Sales", - "rangeAddress": "A:E" -} -``` - -### Example 2: Data Entry Form with Validation - -```json -// Scenario: LLM creating data entry form with dropdowns - -// Step 1: Add status dropdown validation -{ - "tool": "range", - "action": "add-validation", - "sheetName": "Orders", - "rangeAddress": "D2:D1000", - "validation": { - "type": "List", - "formula1": "Pending,Processing,Shipped,Delivered,Cancelled", - "showErrorAlert": true, - "errorStyle": "Stop", - "errorTitle": "Invalid Status", - "errorMessage": "Please select a status from the dropdown list." - } -} - -// Step 2: Add quantity number validation (1-999) -{ - "tool": "range", - "action": "add-validation", - "sheetName": "Orders", - "rangeAddress": "E2:E1000", - "validation": { - "type": "WholeNumber", - "operator": "Between", - "formula1": "1", - "formula2": "999", - "showErrorAlert": true, - "errorTitle": "Invalid Quantity", - "errorMessage": "Quantity must be between 1 and 999." - } -} - -// Step 3: Add email text length validation -{ - "tool": "range", - "action": "add-validation", - "sheetName": "Orders", - "rangeAddress": "C2:C1000", - "validation": { - "type": "TextLength", - "operator": "LessOrEqual", - "formula1": "100", - "showInputMessage": true, - "inputTitle": "Email Address", - "inputMessage": "Enter customer email (max 100 characters)" - } -} -``` - -### Example 3: Table Column Formatting - -```json -// Scenario: LLM formatting table columns professionally - -// Step 1: Format amount column as currency -{ - "tool": "table", - "action": "set-column-number-format", - "tableName": "SalesData", - "columnName": "Amount", - "formatCode": "$#,##0.00" -} - -// Step 2: Format growth column as percentage -{ - "tool": "table", - "action": "set-column-number-format", - "tableName": "SalesData", - "columnName": "Growth", - "formatCode": "0.0%" -} - -// Step 3: Add status dropdown validation -{ - "tool": "table", - "action": "add-column-validation", - "tableName": "SalesData", - "columnName": "Status", - "validation": { - "type": "List", - "formula1": "Active,Inactive,Pending" - } -} - -// Step 4: Center align status column -{ - "tool": "table", - "action": "set-column-alignment", - "tableName": "SalesData", - "columnName": "Status", - "alignment": { "horizontal": "Center" } -} - -// Step 5: Auto-fit all columns -{ - "tool": "table", - "action": "auto-fit-column", - "tableName": "SalesData", - "columnName": "Amount" -} -// Repeat for other columns or use range auto-fit for all at once -``` - -### Example 4: PivotTable Professional Formatting - -```json -// Scenario: LLM creating and formatting a professional PivotTable - -// Step 1: Create PivotTable from table -{ - "tool": "pivottable", - "action": "create-from-table", - "tableName": "SalesData", - "destinationSheet": "Analysis", - "destinationCell": "A1", - "pivotTableName": "SalesPivot" -} - -// Step 2: Configure fields -{ - "tool": "pivottable", - "action": "add-row-field", - "pivotTableName": "SalesPivot", - "fieldName": "Region" -} - -{ - "tool": "pivottable", - "action": "add-column-field", - "pivotTableName": "SalesPivot", - "fieldName": "Quarter" -} - -{ - "tool": "pivottable", - "action": "add-value-field", - "pivotTableName": "SalesPivot", - "fieldName": "Amount", - "function": "Sum", - "customName": "Total Sales" -} - -// Step 3: Format value field as currency -{ - "tool": "pivottable", - "action": "set-field-format", - "pivotTableName": "SalesPivot", - "fieldName": "Total Sales", - "numberFormat": "$#,##0" -} - -// Step 4: Set layout to Tabular (easier to read) -{ - "tool": "pivottable", - "action": "set-layout", - "pivotTableName": "SalesPivot", - "layout": "Tabular" -} - -// Step 5: Apply professional style -{ - "tool": "pivottable", - "action": "set-style", - "pivotTableName": "SalesPivot", - "styleName": "PivotStyleMedium2" -} - -// Step 6: Refresh to show data -{ - "tool": "pivottable", - "action": "refresh", - "pivotTableName": "SalesPivot" -} -``` - ---- - -## LLM Decision Logic - -### 1. Number Format Selection - -When user says a format type, use these codes: - -| User Request | Format Code | Use For | -|--------------|-------------|---------| -| "currency" | `$#,##0.00` | Money amounts | -| "percentage" | `0.00%` | Percentages with 2 decimals | -| "percent" | `0%` | Percentages without decimals | -| "date" | `m/d/yyyy` | US date format | -| "date long" | `mmmm d, yyyy` | Full date (January 1, 2025) | -| "time" | `h:mm AM/PM` | 12-hour time | -| "number" | `#,##0.00` | General numbers with commas | -| "text" | `@` | Force text format | -| "accounting" | See `NumberFormatPresets.Accounting` | Accounting format with alignment | - -### 2. Visual Formatting Decisions - -**Font Formatting:** -``` -User says "make headers bold" → SetFontAsync({ bold: true }) -User says "increase font size" → SetFontAsync({ size: 14 }) -User says "red text" → SetFontAsync({ color: RGB(255, 0, 0) }) -User says "italicize" → SetFontAsync({ italic: true }) -``` - -**Color Application:** -``` -User says "highlight in yellow" → SetBackgroundColorAsync(RGB(255, 255, 0)) -User says "green background" → SetBackgroundColorAsync(RGB(0, 255, 0)) -User says "remove color" → ClearBackgroundColorAsync() -``` - -**Borders:** -``` -User says "add borders" → SetBordersAsync({ style: Continuous, weight: Thin }) -User says "thick border" → SetBordersAsync({ weight: Thick }) -User says "remove borders" → ClearBordersAsync() -``` - -**Alignment:** -``` -User says "center align" → SetAlignmentAsync({ horizontal: Center }) -User says "wrap text" → SetAlignmentAsync({ wrapText: true }) -User says "indent" → SetAlignmentAsync({ indent: 2 }) -``` - -### 3. Validation Type Selection - -``` -User says "dropdown list" or "select from list" - → Type: List, Formula1: "Item1,Item2,Item3" - -User says "number between X and Y" - → Type: WholeNumber/Decimal, Operator: Between, Formula1: "X", Formula2: "Y" - -User says "date range" or "date after X" - → Type: Date, Operator: Greater, Formula1: "1/1/2025" - -User says "maximum length" or "max X characters" - → Type: TextLength, Operator: LessOrEqual, Formula1: "X" - -User says "custom rule" or "formula validation" - → Type: Custom, Formula1: "=AND(A1>0, A1<100)" -``` - -### 4. Range vs Table vs PivotTable Decision - -``` -If user mentions PivotTable or pivot table - → Use pivottable actions - → For value field formatting: use set-field-format - → For layout: use set-layout (Compact, Outline, Tabular) - → For visual style: use set-style (PivotStyleMedium2, etc.) - → Note: Cell-level formatting lost on refresh - use field formats - -If user mentions table name explicitly - → Use table actions (e.g., set-column-number-format) - -If user mentions specific range or worksheet - → use range actions - -For new tables being created - → Create table first, then use table formatting actions - → More efficient than formatting range then converting to table - -For PivotTable creation + formatting workflow - → Create PivotTable → Add fields → Format value fields → Set layout → Set style → Refresh -``` - -### 5. PivotTable Formatting Guide (Critical for LLMs) - -**When User Says "Format the PivotTable":** - -``` -Step 1: Identify what needs formatting - → Value fields (numbers) → Use set-field-format (PERSISTENT) - → Layout/structure → Use set-layout (PERSISTENT) - → Visual appearance → Use set-style (PERSISTENT) - → Specific cells → DANGER: Use range (NOT PERSISTENT - see pitfalls) - -Step 2: Format value fields FIRST (before layout/style) - → Locate value field name (e.g., "Sum of Sales", "Average Price") - → Apply number format using set-field-format - → Examples: - - Currency: "$#,##0.00" or "$#,##0" - - Percentage: "0.00%" or "0%" - - Numbers: "#,##0.00" or "#,##0" - - Dates: "m/d/yyyy" (rarely needed in values) - -Step 3: Set layout for readability - → Compact: Best for space-saving, hierarchical data - → Outline: Best for subtotals and grouping - → Tabular: Best for flat data, easier to read (RECOMMENDED for most cases) - -Step 4: Apply visual style - → Medium styles most popular (PivotStyleMedium2) - → Light styles for subtle appearance - → Dark styles for high contrast - -Step 5: ALWAYS refresh after formatting - → Refresh materializes the formatting changes - → Without refresh, formats may not appear correctly -``` - -**Critical Pitfalls to Avoid:** - -``` -❌ PITFALL 1: Using range to format PivotTable data cells - Problem: Formats lost on next refresh - Example: set-number-format on "C5:C20" (data area) - Why: PivotTable regenerates cells on refresh - Solution: Use set-field-format on value field instead - -❌ PITFALL 2: Formatting before adding all fields - Problem: Layout changes as fields added, formats misaligned - Example: Format cells, then add column field - Solution: Add ALL fields first, THEN format - -❌ PITFALL 3: Forgetting to refresh after formatting - Problem: Formatting doesn't appear or incomplete - Example: set-field-format, then immediately read data - Solution: ALWAYS call refresh action after formatting - -❌ PITFALL 4: Trying to format row/column headers persistently - Problem: Header cells regenerate on refresh - Example: Bold the "Region" header cells - Solution: Not supported persistently - use styles instead - OR format with range knowing it's temporary - -❌ PITFALL 5: Using wrong field name - Problem: Field name is display name, not source name - Example: Field is "Sum of Sales" not "Sales" - Solution: Use list-fields to see actual value field names - Value fields are usually "Sum of X", "Count of Y", etc. - -❌ PITFALL 6: Applying validation to PivotTable cells - Problem: Validation lost on refresh, cells are calculated - Example: Add dropdown to data cells - Solution: Don't add validation to PivotTables - they're read-only summaries -``` - -**Correct PivotTable Formatting Workflow:** - -```javascript -// ✅ CORRECT: Field-level formatting (persistent) -{ - "tool": "pivottable", - "action": "set-field-format", - "pivotTableName": "SalesPivot", - "fieldName": "Sum of Amount", // Use actual field name from list-fields - "numberFormat": "$#,##0" -} - -// ✅ CORRECT: Layout for readability -{ - "tool": "pivottable", - "action": "set-layout", - "pivotTableName": "SalesPivot", - "layout": "Tabular" // Most readable for most users -} - -// ✅ CORRECT: Professional style -{ - "tool": "pivottable", - "action": "set-style", - "pivotTableName": "SalesPivot", - "styleName": "PivotStyleMedium2" // Popular, professional -} - -// ✅ CORRECT: Refresh to materialize -{ - "tool": "pivottable", - "action": "refresh", - "pivotTableName": "SalesPivot" -} - -// ❌ WRONG: Cell-level formatting (lost on refresh) -{ - "tool": "range", - "action": "set-number-format", - "sheetName": "Analysis", - "rangeAddress": "C5:C20", // Don't format PivotTable data cells this way! - "formatCode": "$#,##0.00" -} -``` - -**When to Use Each Formatting Approach:** - -``` -Use set-field-format when: - ✅ User wants to format numbers in PivotTable - ✅ Format needs to persist across refreshes - ✅ Formatting sum, average, count, etc. values - ✅ User says "format the sales column" (value field) - -Use set-layout when: - ✅ User wants to change PivotTable structure - ✅ User says "make it easier to read" - ✅ User wants subtotals shown differently - ✅ Default: Choose Tabular (most readable) - -Use set-style when: - ✅ User wants professional appearance - ✅ User mentions colors, banding, headers - ✅ User says "make it look nice" - ✅ Default: PivotStyleMedium2 or PivotStyleMedium9 - -Use range when (RARE): - ✅ User explicitly wants one-time formatting - ✅ PivotTable will never refresh - ✅ Formatting grand total row/column specifically - ⚠️ WARN USER: Format will be lost on refresh -``` - -**Field Name Discovery Pattern:** - -```javascript -// ALWAYS discover field names first if unsure -{ - "tool": "pivottable", - "action": "list-fields", - "pivotTableName": "SalesPivot" -} - -// Response shows: -// - Fields in Values area: "Sum of Amount", "Average of Price", "Count of Orders" -// - These are the names to use in set-field-format - -// Then format using discovered names: -{ - "tool": "pivottable", - "action": "set-field-format", - "pivotTableName": "SalesPivot", - "fieldName": "Sum of Amount", // Use exact name from list-fields - "numberFormat": "$#,##0.00" -} -``` - -**Common User Requests Translation:** - -``` -User says: "Format the sales numbers as currency" - → set-field-format with fieldName="Sum of Sales" or "Total Sales" - → numberFormat="$#,##0.00" - -User says: "Make the PivotTable easier to read" - → set-layout with layout="Tabular" - → set-style with styleName="PivotStyleMedium2" - -User says: "Make it look professional" - → set-style with styleName="PivotStyleMedium2" or "PivotStyleMedium9" - → Consider set-layout to "Tabular" if currently Compact - -User says: "Format the totals row" - → WARNING: Not supported persistently - → Can use range but warn it's temporary - → Better: Use styles that format totals automatically - -User says: "Add percentage formatting" - → set-field-format with numberFormat="0.00%" or "0%" - → Common for growth, margin, conversion rate fields -``` - -### 5. Batch Operations - -``` -Formatting 3+ columns/ranges - → Use batch mode (begin_excel_batch → operations → commit_excel_batch) - → Example: Format entire sales report (currency, percentages, dates, fonts, borders) - -Single column/range formatting - → Direct action call is fine - -Complete workbook setup (create + format + validate) - → Always use batch mode for consistency -``` - ---- - -## Error Response Structure (Critical for LLM Error Handling) - -### Success Response Format - -All operations return consistent success response: - -```json -{ - "success": true, - "sheetName": "Sales", // Echo back for confirmation - "rangeAddress": "D2:D100", // Echo back for confirmation - "message": "Number format applied successfully" -} -``` - -### Error Response Format - -All operations return consistent error response: - -```json -{ - "success": false, - "errorMessage": "Sheet 'Sales' not found in workbook", - "errorCode": "SHEET_NOT_FOUND" -} -``` - -### Common Error Codes - -| Error Code | Meaning | User Action | -|------------|---------|-------------| -| `SHEET_NOT_FOUND` | Sheet name doesn't exist | List sheets first with worksheet.list | -| `INVALID_RANGE` | Range address malformed | Use "A1:D10" format, check column/row exists | -| `TABLE_NOT_FOUND` | Table name doesn't exist | List tables with table.list | -| `COLUMN_NOT_FOUND` | Column name not in table | List columns with table.get-structured-reference | -| `PIVOTTABLE_NOT_FOUND` | PivotTable name doesn't exist | List pivot tables with pivottable.list | -| `FIELD_NOT_FOUND` | Field name not in PivotTable | List fields with pivottable.list-fields | -| `FIELD_NOT_IN_VALUES` | Field not in Values area | Check area with list-fields, only Values area supports number formats | -| `INVALID_FORMAT_CODE` | Number format code invalid | Use valid Excel format code (e.g., "$#,##0.00", not "currency") | -| `INVALID_VALIDATION_TYPE` | Validation type not recognized | Use: "List", "WholeNumber", "Decimal", "Date", "Time", "TextLength", "Custom" | -| `FILE_NOT_FOUND` | Excel file doesn't exist | Check excelPath is absolute path, file exists | -| `FILE_LOCKED` | Excel file open by another process | Close Excel file first | -| `BATCH_NOT_FOUND` | Batch ID doesn't exist | Check batchId from begin_excel_batch response | - -### Get Operations - Return Value Structure - -**get-number-formats** returns: -```json -{ - "success": true, - "sheetName": "Sales", - "rangeAddress": "A1:C3", - "formats": [ - ["General", "$#,##0.00", "0.00%"], - ["m/d/yyyy", "General", "General"], - ["General", "#,##0", "@"] - ], - "rowCount": 3, - "columnCount": 3 -} -``` - -**get-font** returns: -```json -{ - "success": true, - "sheetName": "Sales", - "rangeAddress": "A1", - "font": { - "name": "Calibri", - "size": 11, - "bold": false, - "italic": false, - "color": 0, - "underline": false, - "strikethrough": false - } -} -``` - -**get-background-color** returns: -```json -{ - "success": true, - "sheetName": "Sales", - "rangeAddress": "A1:E1", - "colors": [ - [15128749, 15128749, 15128749, 15128749, 15128749] - ], - "rowCount": 1, - "columnCount": 5 -} -``` - -**get-borders** returns: -```json -{ - "success": true, - "sheetName": "Sales", - "rangeAddress": "A1", - "borders": { - "top": { "style": "Continuous", "weight": "Thin", "color": 0 }, - "bottom": { "style": "Continuous", "weight": "Thin", "color": 0 }, - "left": { "style": "None", "weight": "Hairline", "color": 0 }, - "right": { "style": "None", "weight": "Hairline", "color": 0 } - } -} -``` - -**get-alignment** returns: -```json -{ - "success": true, - "sheetName": "Sales", - "rangeAddress": "A1:E1", - "alignment": { - "horizontal": "Center", - "vertical": "Bottom", - "wrapText": false, - "indent": 0, - "orientation": 0 - } -} -``` - -**get-validation** returns: -```json -{ - "success": true, - "sheetName": "Sales", - "rangeAddress": "F2:F100", - "validation": { - "type": "List", - "operator": "Between", - "formula1": "Active,Inactive", - "formula2": null, - "ignoreBlank": true, - "showInputMessage": false, - "inputTitle": null, - "inputMessage": null, - "showErrorAlert": true, - "errorStyle": "Stop", - "errorTitle": "Invalid Entry", - "errorMessage": "Please select from dropdown" - } -} -// If no validation exists: -{ - "success": true, - "sheetName": "Sales", - "rangeAddress": "A1", - "validation": null -} -``` - ---- - -## Breaking Changes from Current API - -### ✅ **Acceptable Breaking Changes** - -1. **RangeNumberFormatResult** - New result type (previously planned but not implemented) -2. **Font/Border/Alignment enums** - New types for better type safety -3. **ValidationRule class** - Comprehensive validation configuration (cleaner than multiple parameters) -4. **Table formatting methods** - New methods, no existing API to break - -### ❌ **No Breaking Changes To** - -1. **Existing RangeCommands Phase 1** - All value/formula/clear/copy operations unchanged -2. **Existing TableCommands** - All lifecycle/filter/sort operations unchanged -3. **Result types** - Existing ResultBase, OperationResult, RangeValueResult unchanged - ---- - -## Implementation Strategy - -### Phase 2A: Number Formatting (Priority 1) -**Timeline:** 2-3 days - -**Core Implementation:** -- ✅ Add `GetNumberFormatsAsync`, `SetNumberFormatAsync`, `SetNumberFormatsAsync` to IRangeCommands -- ✅ Implement in RangeCommands.cs using Excel COM Range.NumberFormat -- ✅ Add `NumberFormatPresets` static class -- ✅ Add `RangeNumberFormatResult` type -- ✅ Add table methods: `GetColumnNumberFormatAsync`, `SetColumnNumberFormatAsync` - -**MCP Server:** -- ✅ Add actions to range tool: `get-number-formats`, `set-number-format`, `set-number-formats` -- ✅ Add actions to table tool: `get-column-number-format`, `set-column-number-format` - -**Tests:** -- ✅ Currency format tests ($#,##0.00) -- ✅ Percentage format tests (0.00%) -- ✅ Date format tests (m/d/yyyy) -- ✅ Custom format tests -- ✅ Bulk format tests (2D array) -- ✅ Table column format tests - -**Why First:** Most commonly requested by users, simplest to implement, highest ROI. - ---- - -### Phase 2B: Visual Formatting (Priority 2) -**Timeline:** 3-4 days - -**Core Implementation:** -- ✅ Add 14 font/color/border/alignment methods to IRangeCommands -- ✅ Implement using Excel COM Range.Font, Range.Interior, Range.Borders, alignment properties -- ✅ Add supporting types: FontOptions, BorderOptions, AlignmentOptions, enums -- ✅ Add result types: RangeFontResult, RangeColorResult, RangeBorderResult, RangeAlignmentResult -- ✅ Add table methods for column formatting - -**MCP Server:** -- ✅ Add 14+ actions to range tool -- ✅ Add 4 formatting actions to table tool - -**Tests:** -- ✅ Font tests (bold, italic, size, color) -- ✅ Background color tests (set, get, clear) -- ✅ Border tests (styles, weights) -- ✅ Alignment tests (horizontal, vertical, wrap, indent, rotation) -- ✅ Auto-fit tests -- ✅ Table column visual format tests - -**Why Second:** High user demand, professional appearance, builds on number formatting foundation. - ---- - -### Phase 2C: Data Validation (Priority 3) -**Timeline:** 2-3 days - -**Core Implementation:** -- ✅ Add 4 validation methods to IRangeCommands -- ✅ Implement using Excel COM Range.Validation -- ✅ Add ValidationRule class with all types/operators -- ✅ Add ValidationOperator, ValidationType, ValidationAlertStyle enums -- ✅ Add RangeValidationResult type -- ✅ Add table methods for column validation - -**MCP Server:** -- ✅ Add 4 actions to range tool -- ✅ Add 2 actions to table tool - -**Tests:** -- ✅ List validation tests (dropdown) -- ✅ Number validation tests (whole, decimal, between) -- ✅ Date/time validation tests -- ✅ Text length validation tests -- ✅ Custom formula validation tests -- ✅ Error alert tests -- ✅ Table column validation tests - -**Why Third:** Data quality critical, complex API, requires careful Excel COM handling. - ---- - -### Phase 2D: CLI Implementation (Priority 4) -**Timeline:** 2 days - -**CLI Commands:** -- ✅ `range-get-number-formats`, `range-set-number-format`, `range-set-number-formats` -- ✅ `range-set-font`, `range-set-background-color`, `range-set-borders`, `range-set-alignment` -- ✅ `range-auto-fit-columns`, `range-auto-fit-rows` -- ✅ `range-add-validation`, `range-get-validation`, `range-remove-validation` -- ✅ `table-set-column-number-format`, `table-add-column-validation`, etc. - -**Documentation:** -- ✅ Update README.md -- ✅ Update copilot instructions - ---- - -## Testing Strategy - -### Unit Tests -- Number format code validation -- RGB color conversion -- Enum mapping (BorderStyle, ValidationOperator, etc.) -- ValidationRule validation - -### Integration Tests (Requires Excel) - -**Number Formatting:** -- Set currency format, read back, verify -- Set custom format, verify rendering -- Bulk format 2D array, verify each cell -- Table column format, verify data cells only (not headers) - -**Visual Formatting:** -- Set font properties, verify each property -- Set background color, verify RGB components -- Set borders, verify style/weight/color -- Set alignment, verify horizontal/vertical/wrap -- Auto-fit, verify column widths adjusted - -**Data Validation:** -- Add list validation, verify dropdown appears -- Add number range validation, test invalid input blocked -- Add date validation, verify date picker -- Add custom formula validation, verify evaluation -- Remove validation, verify dropdown removed - -**Table Operations:** -- Format table column, verify only data cells affected -- Add validation to table column, verify auto-expands with new rows -- Auto-fit table columns, verify all columns sized - ---- - -## Success Criteria - -**Phase 2A - Number Formatting:** -- [ ] All 3 range number format methods implemented and tested -- [ ] NumberFormatPresets class with 20+ common patterns -- [ ] 2 table methods working -- [ ] MCP actions functional -- [ ] 10+ integration tests passing - -**Phase 2B - Visual Formatting:** -- [ ] All 14 visual formatting methods implemented and tested -- [ ] Font, border, alignment options working -- [ ] RGB color handling correct -- [ ] Auto-fit functioning -- [ ] 20+ integration tests passing - -**Phase 2C - Data Validation:** -- [ ] All 4 validation methods implemented and tested -- [ ] All validation types working (List, Number, Date, TextLength, Custom) -- [ ] Error alerts and input messages functional -- [ ] Table column validation auto-expanding -- [ ] 15+ integration tests passing - -**Phase 2D - CLI:** -- [ ] All CLI commands implemented -- [ ] Documentation complete -- [ ] CLI tests passing - -**Overall:** -- [ ] All 21 new range methods working -- [ ] All 8 new table methods working -- [ ] 95%+ test coverage -- [ ] MCP Server integration complete -- [ ] Documentation comprehensive -- [ ] Zero regression in existing features - ---- - -## Future Enhancements (Phase 3+) - -### Conditional Formatting (Complex) -- Rule-based formatting (data bars, color scales, icon sets) -- Formula-based rules -- Manage existing conditional formats - -### Cell Merge/Unmerge -- Merge cells in range -- Unmerge cells -- Check merge status - -### Cell Protection -- Lock/unlock cells -- Protect worksheet with password -- Check protection status - -### Advanced Formatting -- Patterns (diagonal lines, dots, etc.) -- Gradient fills -- Custom number formats with conditions - -These can be considered in future iterations based on user demand and complexity vs value analysis. - ---- - -## Summary - -This specification provides a comprehensive, LLM-first approach to Excel formatting and data validation: - -**Key Benefits:** -1. **Professional Output** - LLMs can create polished, formatted reports -2. **Data Quality** - Validation ensures data integrity -3. **User Experience** - Dropdowns and input messages guide users -4. **Consistency** - Unified API across ranges and tables -5. **Performance** - Batch operations for efficiency - -**Total API Surface:** -- 21 new range methods (number formatting: 3, visual: 14, validation: 4) -- 8 new table methods (number formatting: 2, visual: 4, validation: 2) -- 0 new PivotTable methods (already implemented in Phase 1) -- 29+ new MCP actions (range + table only) - -**Implementation Timeline:** -- Phase 2A (Number): 2-3 days -- Phase 2B (Visual): 3-4 days -- Phase 2C (Validation): 2-3 days -- Phase 2D (CLI): 2 days -- **Total: 9-12 days for complete Phase 2** - -This represents a significant enhancement to ExcelMcp's capabilities, enabling LLMs to create production-quality Excel workbooks programmatically. diff --git a/specs/MCP-DAEMON-UNIFICATION-SPEC.md b/specs/MCP-DAEMON-UNIFICATION-SPEC.md deleted file mode 100644 index b4cfef65..00000000 --- a/specs/MCP-DAEMON-UNIFICATION-SPEC.md +++ /dev/null @@ -1,574 +0,0 @@ -# MCP Server Daemon Unification Specification - -## Implementation Status - -> **⚠️ SUPERSEDED** - This spec described the shared-daemon architecture. The actual implementation uses a hybrid model: -> - **MCP Server**: Fully in-process ExcelMcpService with direct method calls (no named pipe) -> - **CLI**: Daemon process with named pipe (`excelmcp-cli-{SID}`) and system tray -> -> See `architecture-patterns.instructions.md` for current architecture. - -### Completed Features (Phase 1) - -- ✅ **Rename Daemon to ExcelMCP Service** - All code, pipes, mutex, lock files updated -- ✅ **Session Origin Tracking** - Sessions labeled [CLI] or [MCP] in tray UI -- ✅ **About Dialog** - Version info and helpful links in tray menu -- ✅ **Removed Manual Daemon Commands** - No more `daemon start/stop/status` commands -- ✅ **Service Client Library** - Shared `ServiceClient/` in ComInterop for CLI and MCP -- ✅ **MCP Server Infrastructure** - Service mode detection and forwarding framework -- ✅ **All MCP Tools Forward to Service** - Removed standalone mode, all tools use `ForwardToService` pattern -- ✅ **Removed Standalone Mode** - No more `EXCELMCP_STANDALONE` or `UseServiceMode` toggles - -### In Progress (Phase 2 - Unified Package) - -- 🔄 **Bundle CLI with MCP Server Package** - Single NuGet package includes both `excelmcp.exe` and `excelcli.exe` -- 🔄 **Deprecate Separate CLI Package** - `Sbroenne.ExcelMcp.CLI` deprecated, points to unified package -- ⏳ **Update ServiceLauncher** - Find `excelcli.exe` next to current executable -- ⏳ **Deduplicate Update Notifications** - Single notification per process lifetime -- ⏳ **Update Release Workflow** - Single unified release artifact - -### Problem Discovered During Testing - -MCP Server tests fail because: -1. Service lives in CLI project (`excelcli service run`) -2. Tests only build MCP Server, not CLI -3. `ServiceLauncher` can't find `excelcli.exe` -4. Installing MCP-only doesn't include the service - -### Solution: Unified Package (Simpler Than Service Extraction) - -Instead of extracting a separate service project, **bundle CLI with MCP Server**: - -``` -Sbroenne.ExcelMcp.McpServer → excelmcp.exe + excelcli.exe (both included) -Sbroenne.ExcelMcp.CLI → DEPRECATED (points to McpServer package) -``` - -**Benefits:** -- ✅ No version mismatch possible (everything upgrades together) -- ✅ No new project needed (keep service in CLI) -- ✅ Simpler release (one package) -- ✅ MCP always finds service (excelcli.exe next to excelmcp.exe) - -**Installation (After):** -```powershell -# One package, both tools -dotnet tool install --global Sbroenne.ExcelMcp.McpServer - -# Both commands available -excelmcp # MCP Server for AI assistants -excelcli # CLI for coding agents -``` - -### Architecture - -**Service-Only Mode**: MCP Server is now a thin JSON-over-named-pipe layer that forwards ALL requests to the ExcelMCP Service. This enables CLI and MCP Server to share sessions transparently. - -``` -MCP Client (VS Code, etc.) - │ - ▼ -┌──────────────────────────┐ -│ MCP Server │ -│ ForwardToService() │ ──────► Named Pipe: excelmcp-{UserSid} -│ (no local Core cmds) │ -└──────────────────────────┘ - │ - ▼ - ┌──────────────────────────┐ - │ ExcelMCP Service │ - │ (runs via excelcli) │ - │ ┌────────────────────┐ │ - │ │ SessionManager │ │ - │ │ (shared sessions) │ │ - │ └────────────────────┘ │ - └──────────────────────────┘ -``` - ---- - -## Phase 2: Service Extraction (Current Work) - -### Problem: Deployment Mismatch - -**User installs ONLY MCP Server:** -```powershell -dotnet tool install --global Sbroenne.ExcelMcp.McpServer -``` -- MCP Server tries to start `excelcli.exe service run` -- `excelcli.exe` doesn't exist because CLI isn't installed -- **All operations fail** ❌ - -### Solution: Separate Service Project - -Create `ExcelMcp.Service` as an independent project that produces `excelservice.exe`: - -``` -src/ - ExcelMcp.Service/ ← NEW PROJECT - ExcelMcp.Service.csproj ← net10.0-windows (WinForms for tray) - Program.cs ← Entry point - ExcelMcpService.cs ← Moved from CLI/Service/ - ServiceTray.cs ← Moved from CLI/Service/ - ... - - ExcelMcp.CLI/ - ExcelMcp.CLI.csproj ← BUNDLES excelservice.exe - Commands/ ← CLI commands only - - ExcelMcp.McpServer/ - ExcelMcp.McpServer.csproj ← BUNDLES excelservice.exe - Tools/ ← MCP tools only -``` - -### Deployment Scenarios - -**User installs CLI only:** -``` -~/.dotnet/tools/ - excelcli.exe ← CLI tool - excelservice.exe ← Bundled service -``` -✅ CLI finds service next to itself - -**User installs MCP only:** -``` -~/.dotnet/tools/ - excelmcp.exe ← MCP Server - excelservice.exe ← Bundled service -``` -✅ MCP finds service next to itself - -**User installs BOTH:** -``` -~/.dotnet/tools/ - excelcli.exe - excelmcp.exe - excelservice.exe ← One copy, shared -``` -✅ Either can start it, sessions are shared - -### Version Mismatch Handling - -**Scenario:** User has CLI v1.5 (with Service v1.5) and updates MCP to v1.6 (with Service v1.6) - -**Problem:** -- CLI starts service v1.5 -- MCP connects and expects v1.6 protocol -- Potential incompatibility! - -**Solution: "Latest Wins" Strategy** - -```csharp -// On client startup (both CLI and MCP): -public async Task<bool> EnsureServiceRunningAsync() -{ - var runningVersion = await GetRunningServiceVersionAsync(); - var bundledVersion = GetBundledServiceVersion(); - - if (runningVersion == null) - { - // No service running, start bundled version - return await StartServiceAsync(); - } - - if (bundledVersion > runningVersion) - { - // Bundled version is newer - upgrade! - await RequestServiceShutdownAsync(); - await WaitForServiceExitAsync(); - return await StartServiceAsync(); - } - - // Running version is same or newer - use it - return true; -} -``` - -**Protocol Additions:** - -```json -// Ping response includes version -{ - "success": true, - "version": "1.6.0", - "uptime": "00:15:30" -} - -// Graceful shutdown command -{ - "command": "service.shutdown", - "reason": "upgrade" -} -``` - -**Compatibility Rules:** -- Same major version = compatible (v1.5 client can use v1.6 service) -- Different major version = force upgrade (v2.0 client shuts down v1.x service) -- Service maintains backward compatibility within major version - -### Files to Move - -**From `CLI/Service/` to new `Service/` project:** -- `ExcelMcpService.cs` (2282 lines - the main service) -- `ServiceTray.cs` - Windows Forms tray icon -- `DialogService.cs` - About dialog -- `ServiceProtocol.cs` - Command routing -- `ServiceSecurity.cs` (service-side parts) - Lock files, mutex - -**Keep in ComInterop (shared client code):** -- `ServiceClient/ExcelServiceClient.cs` - Named pipe client -- `ServiceClient/ServiceLauncher.cs` - Find and start service -- `ServiceClient/ServiceSecurity.cs` (read-only parts) - Check if running - -### NuGet Packaging - -Both CLI and MCP Server `.csproj` files need to bundle `excelservice.exe`: - -```xml -<ItemGroup> - <!-- Bundle the service executable --> - <None Include="$(OutputPath)\..\ExcelMcp.Service\net10.0-windows\excelservice.exe" - Pack="true" - PackagePath="tools\net10.0-windows\any\" /> -</ItemGroup> -``` - -### ServiceLauncher Simplification - -```csharp -private static ProcessStartInfo? GetServiceStartInfo() -{ - // Primary: Look next to current executable - var serviceExe = Path.Combine(AppContext.BaseDirectory, "excelservice.exe"); - if (File.Exists(serviceExe)) - { - return new ProcessStartInfo - { - FileName = serviceExe, - UseShellExecute = true, - CreateNoWindow = true, - WindowStyle = ProcessWindowStyle.Hidden - }; - } - - // Fallback: Global tools location - var globalTools = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".dotnet", "tools", "excelservice.exe"); - - if (File.Exists(globalTools)) - { - return new ProcessStartInfo - { - FileName = globalTools, - UseShellExecute = true, - CreateNoWindow = true, - WindowStyle = ProcessWindowStyle.Hidden - }; - } - - return null; -} -``` - ---- - -## Overview - -Unify the MCP Server with the existing CLI daemon architecture to provide persistent session management across both interfaces. - -## Problem Statement (Phase 1 - Completed) - -### Current Architecture - -``` -┌──────────────────────────┐ ┌──────────────────────────┐ -│ MCP Server #1 │ │ MCP Server #2 │ -│ ┌────────────────────┐ │ │ ┌────────────────────┐ │ -│ │ SessionManager │ │ │ │ SessionManager │ │ -│ │ (isolated) │ │ │ │ (isolated) │ │ -│ └────────────────────┘ │ │ └────────────────────┘ │ -│ │ │ │ │ │ -│ Excel Process A │ │ Excel Process B │ -└──────────────────────────┘ └──────────────────────────┘ - ↑ ↑ - │ │ - File: test.xlsx ─────────────── File: test.xlsx - ❌ CONFLICT! -``` - -**Issues:** -1. Each MCP server process has its own `SessionManager` -2. Each opens separate Excel processes -3. File locking conflicts when multiple MCP servers access the same file -4. Sessions lost when MCP server process restarts -5. No visibility into sessions (no tray UI) - -### CLI Daemon Architecture (Already Working) - -``` -┌─────────────────┐ Named Pipe ┌──────────────────────────────────┐ -│ CLI Command 1 │ ──────────────── │ │ -└─────────────────┘ │ ExcelDaemon │ - │ │ -┌─────────────────┐ Named Pipe │ • SessionManager (singleton) │ -│ CLI Command 2 │ ──────────────── │ • Tray Icon │ -└─────────────────┘ │ • 10-min idle timeout │ - │ • Single instance mutex │ - │ │ - └──────────────────────────────────┘ -``` - -**Benefits:** -- Single `SessionManager` across all CLI invocations -- Sessions persist between commands -- Tray UI shows active sessions -- Automatic cleanup via idle timeout - -## Proposed Architecture - -``` - ┌──────────────────────────────────┐ -┌─────────────────┐ │ │ -│ CLI Commands │──Named Pipe────▶│ │ -└─────────────────┘ │ ExcelDaemon │ - │ (Unified) │ -┌─────────────────┐ │ │ -│ MCP Server #1 │──Named Pipe────▶│ • SessionManager (singleton) │ -└─────────────────┘ │ • Tray Icon (all sessions) │ - │ • 10-min idle timeout │ -┌─────────────────┐ │ • Single instance mutex │ -│ MCP Server #2 │──Named Pipe────▶│ • Core Commands │ -└─────────────────┘ │ │ - └──────────────────────────────────┘ - │ - Excel Processes - (one per open file) -``` - -**Benefits:** -1. ✅ Single `SessionManager` for CLI and MCP -2. ✅ No file locking conflicts between MCP instances -3. ✅ Sessions survive MCP server restarts -4. ✅ Unified tray UI shows all sessions -5. ✅ MCP Server becomes thin wrapper (less code to maintain) -6. ✅ LLM tests can use multiple turns without race conditions - -## Implementation Plan - -### Phase 1: Extract Daemon Client Library - -**Goal:** Create reusable client library that both CLI and MCP can use. - -**New Project:** `ExcelMcp.Daemon.Client` - -```csharp -namespace Sbroenne.ExcelMcp.Daemon.Client; - -public class DaemonClient : IDisposable -{ - public static DaemonClient Connect(bool autoStartDaemon = true); - public Task<string> SendCommandAsync(string toolName, string action, Dictionary<string, object> parameters); - public bool IsDaemonRunning { get; } -} -``` - -**Files to create:** -- `src/ExcelMcp.Daemon.Client/DaemonClient.cs` -- `src/ExcelMcp.Daemon.Client/DaemonProtocol.cs` (shared message types) -- `src/ExcelMcp.Daemon.Client/DaemonLauncher.cs` (auto-start logic) - -### Phase 2: Refactor CLI to Use Client Library - -**Goal:** CLI uses `DaemonClient` instead of direct pipe operations. - -**Changes:** -- Extract pipe communication from `ExcelDaemon.cs` into shared protocol -- CLI commands use `DaemonClient.SendCommandAsync()` -- Verify existing CLI tests still pass - -### Phase 3: MCP Server Uses Daemon - -**Goal:** MCP Server tools forward requests to daemon. - -**Before:** -```csharp -public class ExcelFileTool -{ - private static readonly SessionManager _sessionManager = new(); - - public static string Open(string path, bool showExcel) - { - var batch = _sessionManager.CreateSession(path, showExcel); - // Complex session management... - } -} -``` - -**After:** -```csharp -public class ExcelFileTool -{ - public static async Task<string> Open(string path, bool showExcel) - { - using var client = DaemonClient.Connect(); - return await client.SendCommandAsync("file", "open", new { path, showExcel }); - } -} -``` - -### Phase 4: Enhanced Tray UI - -**Goal:** Tray shows session source (CLI vs MCP). - -**Changes:** -- Track session origin in `SessionManager` -- Show in tray tooltip: "2 MCP sessions, 1 CLI session" -- Context menu: "Close all MCP sessions", "Close all CLI sessions" - -## Protocol Design - -### Request Format (JSON over Named Pipe) - -```json -{ - "id": "uuid-v4", - "tool": "file", - "action": "open", - "parameters": { - "excelPath": "C:\\test.xlsx", - "showExcel": false - }, - "source": "mcp-server" -} -``` - -### Response Format - -```json -{ - "id": "uuid-v4", - "success": true, - "result": { - "sessionId": "abc123", - "filePath": "C:\\test.xlsx" - } -} -``` - -### Error Response - -```json -{ - "id": "uuid-v4", - "success": false, - "error": { - "message": "File not found", - "code": "FILE_NOT_FOUND" - } -} -``` - -## Migration Strategy - -> **Note:** This section describes the original migration plan. As of February 2026, standalone mode has been **removed entirely**. The MCP Server now operates exclusively in service mode using `ForwardToService` pattern. - -### ~~Backward Compatibility~~ (Superseded) - -~~1. MCP Server can work in **two modes:**~~ - ~~- **Daemon mode** (default): Forward to daemon~~ - ~~- **Standalone mode** (fallback): Use embedded `SessionManager`~~ - -**Current Implementation:** Service-only mode. All MCP tools use `ForwardToService()` to send commands to the ExcelMCP Service via named pipe. - -### Testing Strategy - -1. **Unit tests:** Mock `DaemonClient`, test tool logic -2. **Integration tests:** Start daemon, run MCP tests -3. **LLM tests:** Multi-turn workflows (the original problem!) - -## File Structure Changes - -``` -src/ -├── ExcelMcp.Daemon.Client/ # NEW: Shared client library -│ ├── DaemonClient.cs -│ ├── DaemonProtocol.cs -│ └── DaemonLauncher.cs -├── ExcelMcp.CLI/ -│ ├── Daemon/ -│ │ └── ExcelDaemon.cs # MODIFIED: Use shared protocol -│ └── Commands/ # MODIFIED: Use DaemonClient -├── ExcelMcp.McpServer/ -│ └── Tools/ # MODIFIED: Use DaemonClient -└── ExcelMcp.Core/ # UNCHANGED -``` - -## Risks and Mitigations - -| Risk | Impact | Mitigation | -|------|--------|------------| -| Daemon startup latency | First call slow | Pre-launch daemon on install, lazy connect | -| Daemon crashes | All sessions lost | Robust error handling, reconnect logic | -| Protocol versioning | Breaking changes | Version field in protocol, "latest wins" upgrade | -| Security | Named pipe access | Keep existing security (per-user pipe) | -| Debugging complexity | Two processes | Unified logging, trace correlation | -| Version mismatch CLI/MCP | Incompatible protocols | Service version check, automatic upgrade | -| Duplicate services | Race condition on startup | Mutex + lock file, version-aware handoff | - -## Success Criteria - -### Phase 1 (Completed) -1. ✅ MCP Server can complete 5-turn workflow without file locking -2. ✅ CLI and MCP sessions visible in same tray UI -3. ✅ Session survives MCP server restart -4. ✅ No performance regression (< 50ms added latency) -5. ✅ Removed standalone mode - service-only architecture - -### Phase 2 (In Progress) -6. ⏳ MCP-only install works (no CLI required) -7. ⏳ CLI-only install works (no MCP required) -8. ⏳ Version mismatch auto-upgrades service -9. ⏳ Single update notification per process lifetime -10. ⏳ All MCP Server tests pass - -## Timeline Estimate - -### Phase 1 (Completed) -- ✅ Extract client library: 2 days -- ✅ Refactor CLI to use client: 1 day -- ✅ MCP integration: 3 days -- ✅ Tray enhancements: 1 day -- ✅ Remove standalone mode: 1 day - -### Phase 2 (Current) -- 🔄 Create ExcelMcp.Service project: 1 day -- ⏳ Move service code from CLI: 1 day -- ⏳ Bundle service in NuGet packages: 1 day -- ⏳ Version check and upgrade logic: 1 day -- ⏳ Fix duplicate update notifications: 0.5 day -- ⏳ Update tests: 1 day -- ⏳ Documentation: 0.5 day - -**Phase 2 Total:** ~6 days - -## Open Questions (Updated) - -1. ~~Should daemon auto-start when MCP server connects?~~ - - **RESOLVED:** Yes, always auto-start - -2. ~~Should we support multiple daemons (per-workspace)?~~ - - **RESOLVED:** No, single daemon per user - -3. ~~What happens if daemon exits while MCP is running?~~ - - **RESOLVED:** Client automatically reconnects and restarts service - -4. **NEW:** What if both CLI and MCP try to upgrade service simultaneously? - - **Recommendation:** First one wins (mutex), second waits and connects - -5. **NEW:** Should we show "upgrade in progress" to user? - - **Recommendation:** Yes, brief tray notification - -6. **NEW:** How long to wait for old service to shut down? - - **Recommendation:** 5 seconds timeout, then force-kill process diff --git a/specs/PIVOTTABLE-API-SPECIFICATION.md b/specs/PIVOTTABLE-API-SPECIFICATION.md deleted file mode 100644 index 367cead4..00000000 --- a/specs/PIVOTTABLE-API-SPECIFICATION.md +++ /dev/null @@ -1,1546 +0,0 @@ -# Excel PivotTable API Specification - -> **Comprehensive specification for Excel PivotTable operations - creating, managing, and analyzing data with pivot functionality** - -## Implementation Status - -**Phase 1 (MVP): ✅ 100% COMPLETE** (As of October 30, 2025) -- ✅ All 19 core operations implemented in `PivotTableCommands` -- ✅ Complete MCP Server integration (`pivottable` tool with 25 actions) -- ✅ CLI commands implemented -- ✅ Integration tests passing -- ✅ Covers 95% of common LLM/AI agent use cases - -**Phase 2 (Advanced): ✅ SUBSTANTIALLY COMPLETE** (As of November 22, 2025) -- ✅ Grouping operations: `GroupByDate`, `GroupByNumeric` (custom text grouping NOT implemented) -- ✅ Calculated fields: `CreateCalculatedField` (update/delete/list NOT implemented) -- ✅ Layout configuration: `SetLayout` (Compact, Tabular, Outline) -- ✅ Subtotals control: `SetSubtotals` (per-field configuration) -- ✅ Grand totals control: `SetGrandTotals` (row and column independent) -- ✅ Slicer integration: `CreateSlicer`, `ListSlicers`, `SetSlicerSelection`, `DeleteSlicer` -- ❌ Drill-down functionality (NOT IMPLEMENTED) -- ❌ Advanced data source management: `ChangeDataSourceAsync`, `GetCacheInfoAsync` (NOT IMPLEMENTED) - -**Total Implemented: 29 operations (19 Phase 1 + 6 Phase 2 + 4 Slicer)** - -See **Success Criteria** section below for detailed checklist. - ---- - -## Executive Summary - -This specification defines a **PivotTable API** for ExcelMcp that provides complete PivotTable lifecycle management, field configuration, and data analysis capabilities through Excel COM automation. - -### Key Design Decisions - -1. **COM-Backed Only** - Every operation uses native Excel COM PivotTable objects -2. **Data Source Agnostic** - Support Excel ranges, tables, external connections, and Data Model -3. **Field-Centric Design** - Operations organized around PivotTable fields and areas -4. **Cache Management** - Proper handling of PivotCache for performance and data refresh -5. **Layout Preservation** - Maintain existing PivotTable structure during modifications - -### Goals - -1. **Complete Lifecycle** - Create, configure, refresh, and delete PivotTables -2. **Field Management** - Add/remove/configure fields in all areas (Rows, Columns, Values, Filters) -3. **Data Analysis** - Sorting, filtering, grouping, and drill-down capabilities -4. **Performance** - Efficient cache management and bulk operations -5. **Power User Features** - Advanced formatting, calculated fields, and slicers - ---- - -## Excel PivotTable Architecture - -### What is a PivotTable? - -Excel PivotTables are **interactive data summarization tools** that provide: -- Dynamic data aggregation and cross-tabulation -- Drag-and-drop field configuration -- Multiple aggregation functions (Sum, Count, Average, etc.) -- Hierarchical grouping and drilling -- Interactive filtering and slicing -- Calculated fields and items -- Professional formatting and styling - -### Excel COM Object Model - -#### Core Objects -```csharp -// PivotTable object hierarchy -dynamic worksheet = workbook.Worksheets.Item("Sheet1"); -dynamic pivotTables = worksheet.PivotTables; -dynamic pivotTable = pivotTables.Item("PivotTable1"); - -// PivotCache (data source) -dynamic pivotCache = pivotTable.PivotCache; - -// PivotFields (columns from source data) -dynamic pivotFields = pivotTable.PivotFields; -dynamic field = pivotFields.Item("Sales"); - -// Field Areas -dynamic rowFields = pivotTable.RowFields; // Row area -dynamic columnFields = pivotTable.ColumnFields; // Column area -dynamic dataFields = pivotTable.DataFields; // Values area -dynamic pageFields = pivotTable.PageFields; // Filter area -``` - -### PivotTable Creation Workflow - -```csharp -// VALIDATED COM API PATTERNS (from Excel VBA documentation) - -// 1. Create PivotCache from data source -dynamic pivotCaches = workbook.PivotCaches(); -dynamic pivotCache = pivotCaches.Create( - SourceType: 1, // xlDatabase = 1 - SourceData: "Sheet1!A1:F100" // Data range with headers -); - -// 2. Create PivotTable from cache -dynamic destinationSheet = workbook.Worksheets.Item("Sheet2"); -dynamic pivotTable = pivotCache.CreatePivotTable( - TableDestination: destinationSheet.Range["A1"], // Range object, not string - TableName: "SalesPivot" -); - -// 3. Configure fields with proper constants -dynamic salesField = pivotTable.PivotFields.Item("Sales"); -salesField.Orientation = 4; // xlDataField = 4 -salesField.Function = -4157; // xlSum = -4157 - -dynamic regionField = pivotTable.PivotFields.Item("Region"); -regionField.Orientation = 1; // xlRowField = 1 - -// 4. CRITICAL: Refresh to materialize layout -pivotTable.RefreshTable(); -``` - ---- - -## Proposed PivotTable API Design - -### Design Principles - -1. **COM-Backed Only**: Every method uses native Excel COM PivotTable operations -2. **Cache-Aware**: Proper PivotCache management for performance and data integrity -3. **Field-Centric**: Operations organized around PivotTable field management -4. **Incremental Configuration**: Build PivotTables step-by-step with validation -5. **Error Recovery**: Handle invalid field configurations gracefully - -## LLM-Optimized API Design - -### Design Principles for AI Agents - -1. **COM-Backed with Validated Constants**: Every method uses correct Excel COM constants and error handling -2. **Meaningful Result Validation**: Integration tests verify actual PivotTable structure, not just success status -3. **Incremental Build Pattern**: LLMs can build PivotTables step-by-step with immediate feedback -4. **Error Recovery**: Clear error messages with actionable guidance for field configuration issues -5. **Data Source Transparency**: Auto-detect and validate data source types (range, table, Data Model) - -### LLM Usage Patterns - -**As an LLM, I need to:** - -1. **Create PivotTable from existing data** - Auto-detect data boundaries and headers -2. **Add fields incrementally** - Get immediate feedback on field placement and validation -3. **Verify layout changes** - Read back PivotTable structure after each modification -4. **Handle configuration errors** - Graceful recovery when fields don't exist or have wrong types -5. **Access result data** - Read PivotTable output for analysis and reporting - -### Phase 1: LLM-First Core Operations (MVP) - -#### IPivotTableCommands Interface (Validated COM Patterns) - -```csharp -public interface IPivotTableCommands -{ - // === LIFECYCLE OPERATIONS === - - /// <summary> - /// Lists all PivotTables in workbook with structure details - /// Returns: Name, sheet, range, source data, field counts, last refresh - /// </summary> - Task<PivotTableListResult> ListAsync(IExcelBatch batch); - - /// <summary> - /// Gets complete PivotTable configuration and current layout - /// Returns: All fields with positions, aggregation functions, filter states - /// </summary> - Task<PivotTableInfoResult> GetInfoAsync(IExcelBatch batch, string pivotTableName); - - /// <summary> - /// Creates PivotTable from Excel range with auto-detection of headers - /// Validates: Source range exists, has headers, contains data - /// Returns: Created PivotTable name and initial field list - /// </summary> - Task<PivotTableCreateResult> CreateFromRangeAsync(IExcelBatch batch, - string sourceSheet, string sourceRange, - string destinationSheet, string destinationCell, - string pivotTableName); - - /// <summary> - /// Creates PivotTable from Excel Table (ListObject) - /// Validates: Table exists, has data rows - /// Returns: Created PivotTable name and available fields - /// </summary> - Task<PivotTableCreateResult> CreateFromTableAsync(IExcelBatch batch, - string tableName, - string destinationSheet, string destinationCell, - string pivotTableName); - - /// <summary> - /// Deletes PivotTable completely - /// Validates: PivotTable exists before deletion - /// </summary> - Task<OperationResult> DeleteAsync(IExcelBatch batch, string pivotTableName); - - /// <summary> - /// Refreshes PivotTable data from source and returns updated info - /// Returns: Refresh timestamp, record count, any structural changes - /// </summary> - Task<PivotTableRefreshResult> RefreshAsync(IExcelBatch batch, string pivotTableName); - - // === FIELD MANAGEMENT (WITH IMMEDIATE VALIDATION) === - - /// <summary> - /// Lists all available fields and their current placement - /// Returns: Field names, data types, current areas, aggregation functions - /// </summary> - Task<PivotFieldListResult> ListFieldsAsync(IExcelBatch batch, string pivotTableName); - - /// <summary> - /// Adds field to Row area with position validation - /// Validates: Field exists, not already in another area - /// Returns: Updated field layout with new position - /// </summary> - Task<PivotFieldResult> AddRowFieldAsync(IExcelBatch batch, string pivotTableName, - string fieldName, int? position = null); - - /// <summary> - /// Adds field to Column area with position validation - /// Validates: Field exists, not already in another area - /// Returns: Updated field layout - /// </summary> - Task<PivotFieldResult> AddColumnFieldAsync(IExcelBatch batch, string pivotTableName, - string fieldName, int? position = null); - - /// <summary> - /// Adds field to Values area with aggregation function - /// Validates: Field exists, aggregation function is appropriate for data type - /// Returns: Field configuration with applied function and custom name - /// </summary> - Task<PivotFieldResult> AddValueFieldAsync(IExcelBatch batch, string pivotTableName, - string fieldName, AggregationFunction function = AggregationFunction.Sum, - string? customName = null); - - /// <summary> - /// Adds field to Filter area (Page field) - /// Validates: Field exists, returns available filter values - /// Returns: Field configuration with available filter items - /// </summary> - Task<PivotFieldResult> AddFilterFieldAsync(IExcelBatch batch, string pivotTableName, - string fieldName); - - /// <summary> - /// Removes field from any area - /// Validates: Field is currently placed in PivotTable - /// Returns: Updated layout after removal - /// </summary> - Task<PivotFieldResult> RemoveFieldAsync(IExcelBatch batch, string pivotTableName, - string fieldName); - - /// <summary> - /// Moves field between areas with validation - /// Validates: Valid source/target areas, position constraints - /// Returns: New field configuration and layout - /// </summary> - Task<PivotFieldResult> MoveFieldAsync(IExcelBatch batch, string pivotTableName, - string fieldName, PivotFieldArea fromArea, PivotFieldArea toArea, - int? position = null); - - // === FIELD CONFIGURATION (WITH RESULT VERIFICATION) === - - /// <summary> - /// Sets aggregation function for value field - /// Validates: Field is in Values area, function is valid for data type - /// Returns: Applied function and sample calculation result - /// </summary> - Task<PivotFieldResult> SetFieldFunctionAsync(IExcelBatch batch, string pivotTableName, - string fieldName, AggregationFunction function); - - /// <summary> - /// Sets custom name for field in any area - /// Validates: Name doesn't conflict with existing fields - /// Returns: Applied name and field reference - /// </summary> - Task<PivotFieldResult> SetFieldNameAsync(IExcelBatch batch, string pivotTableName, - string fieldName, string customName); - - /// <summary> - /// Sets number format for value field - /// Validates: Field is in Values area, format string is valid - /// Returns: Applied format with sample formatted value - /// </summary> - Task<PivotFieldResult> SetFieldFormatAsync(IExcelBatch batch, string pivotTableName, - string fieldName, string numberFormat); - - // === ANALYSIS OPERATIONS (WITH DATA VALIDATION) === - - /// <summary> - /// Gets current PivotTable data as 2D array for LLM analysis - /// Returns: Values with headers, row/column labels, formatted numbers - /// </summary> - Task<PivotTableDataResult> GetDataAsync(IExcelBatch batch, string pivotTableName); - - /// <summary> - /// Sets filter for field with validation of filter items - /// Validates: Field supports filtering, selected values exist - /// Returns: Applied filter state and affected row count - /// </summary> - Task<PivotFieldFilterResult> SetFieldFilterAsync(IExcelBatch batch, string pivotTableName, - string fieldName, List<string> selectedValues); - - /// <summary> - /// Sorts field with immediate layout update - /// Validates: Field can be sorted, returns new sort order - /// Returns: Applied sort configuration and preview of changes - /// </summary> - Task<PivotFieldResult> SortFieldAsync(IExcelBatch batch, string pivotTableName, - string fieldName, SortDirection direction = SortDirection.Ascending); -} - -// === LLM-FOCUSED RESULT TYPES === - -public class PivotTableCreateResult : OperationResult -{ - public string PivotTableName { get; set; } = string.Empty; - public string SheetName { get; set; } = string.Empty; - public string Range { get; set; } = string.Empty; - public string SourceData { get; set; } = string.Empty; - public int SourceRowCount { get; set; } - public List<string> AvailableFields { get; set; } = new(); - public List<string> NumericFields { get; set; } = new(); // Suggested for Values area - public List<string> TextFields { get; set; } = new(); // Suggested for Rows/Columns/Filters - public List<string> DateFields { get; set; } = new(); // Suggested for grouping -} - -public class PivotTableRefreshResult : OperationResult -{ - public string PivotTableName { get; set; } = string.Empty; - public DateTime RefreshTime { get; set; } - public int SourceRecordCount { get; set; } - public int PreviousRecordCount { get; set; } - public bool StructureChanged { get; set; } - public List<string> NewFields { get; set; } = new(); // Fields added to source - public List<string> RemovedFields { get; set; } = new(); // Fields removed from source -} - -public class PivotFieldResult : OperationResult -{ - public string FieldName { get; set; } = string.Empty; - public string CustomName { get; set; } = string.Empty; - public PivotFieldArea Area { get; set; } - public int Position { get; set; } - public AggregationFunction? Function { get; set; } - public string? NumberFormat { get; set; } - public List<string> AvailableValues { get; set; } = new(); // For filtering - public object? SampleValue { get; set; } // Example of formatted output - public string DataType { get; set; } = string.Empty; // Text, Number, Date, Boolean -} - -public class PivotTableDataResult : OperationResult -{ - public string PivotTableName { get; set; } = string.Empty; - public List<List<object?>> Values { get; set; } = new(); // 2D array of PivotTable data - public List<string> ColumnHeaders { get; set; } = new(); // Column field values - public List<string> RowHeaders { get; set; } = new(); // Row field values - public int DataRowCount { get; set; } - public int DataColumnCount { get; set; } - public Dictionary<string, object?> GrandTotals { get; set; } = new(); // Summary values -} - -public class PivotFieldFilterResult : OperationResult -{ - public string FieldName { get; set; } = string.Empty; - public List<string> SelectedItems { get; set; } = new(); - public List<string> AvailableItems { get; set; } = new(); - public int VisibleRowCount { get; set; } // Rows shown after filter - public int TotalRowCount { get; set; } // Total rows before filter - public bool ShowAll { get; set; } -} -``` - -### Phase 2: Advanced Operations - -```csharp -public interface IPivotTableCommands -{ - // === GROUPING OPERATIONS === - - /// <summary> - /// Groups date field by period (years, quarters, months, days) - /// </summary> - Task<OperationResult> GroupDateFieldAsync(IExcelBatch batch, string pivotTableName, string fieldName, - DateGrouping groupBy, DateTime? startDate = null, DateTime? endDate = null); - - /// <summary> - /// Groups numeric field by ranges - /// </summary> - Task<OperationResult> GroupNumericFieldAsync(IExcelBatch batch, string pivotTableName, string fieldName, - double? startValue, double? endValue, double interval); - - /// <summary> - /// Creates custom grouping for text field - /// </summary> - Task<OperationResult> CreateCustomGroupAsync(IExcelBatch batch, string pivotTableName, string fieldName, - string groupName, List<string> items); - - /// <summary> - /// Ungroups a field - /// </summary> - Task<OperationResult> UngroupFieldAsync(IExcelBatch batch, string pivotTableName, string fieldName); - - // === CALCULATED FIELDS === - - /// <summary> - /// Creates a calculated field with custom formula - /// </summary> - Task<OperationResult> CreateCalculatedFieldAsync(IExcelBatch batch, string pivotTableName, - string fieldName, string formula); - - /// <summary> - /// Updates calculated field formula - /// </summary> - Task<OperationResult> UpdateCalculatedFieldAsync(IExcelBatch batch, string pivotTableName, - string fieldName, string formula); - - /// <summary> - /// Deletes calculated field - /// </summary> - Task<OperationResult> DeleteCalculatedFieldAsync(IExcelBatch batch, string pivotTableName, string fieldName); - - /// <summary> - /// Lists all calculated fields - /// </summary> - Task<CalculatedFieldListResult> ListCalculatedFieldsAsync(IExcelBatch batch, string pivotTableName); - - // === DRILL DOWN === - - /// <summary> - /// Gets drill-down data for a specific cell in PivotTable - /// </summary> - Task<PivotDrillDownResult> DrillDownAsync(IExcelBatch batch, string pivotTableName, - string targetSheet, string cellAddress); - - // === SLICER INTEGRATION === - - /// <summary> - /// Creates a slicer for a PivotTable field - /// </summary> - Task<OperationResult> CreateSlicerAsync(IExcelBatch batch, string pivotTableName, string fieldName, - string slicerName, string destinationSheet, string position); - - /// <summary> - /// Lists all slicers connected to PivotTable - /// </summary> - Task<SlicerListResult> ListSlicersAsync(IExcelBatch batch, string pivotTableName); - - /// <summary> - /// Sets slicer selection - /// </summary> - Task<OperationResult> SetSlicerSelectionAsync(IExcelBatch batch, string slicerName, List<string> selectedItems); - - // === DATA SOURCE MANAGEMENT === - - /// <summary> - /// Changes PivotTable data source - /// </summary> - Task<OperationResult> ChangeDataSourceAsync(IExcelBatch batch, string pivotTableName, - string newSourceSheet, string newSourceRange); - - /// <summary> - /// Gets PivotCache information - /// </summary> - Task<PivotCacheInfoResult> GetCacheInfoAsync(IExcelBatch batch, string pivotTableName); -} - -// === ADDITIONAL SUPPORTING TYPES === - -public enum DateGrouping -{ - Years, - Quarters, - Months, - Days, - Hours, - Minutes, - Seconds -} - -public class CalculatedFieldInfo -{ - public string Name { get; set; } = string.Empty; - public string Formula { get; set; } = string.Empty; -} - -public class CalculatedFieldListResult : OperationResult -{ - public List<CalculatedFieldInfo> CalculatedFields { get; set; } = new(); -} - -public class PivotDrillDownResult : OperationResult -{ - public string DrillDownSheet { get; set; } = string.Empty; - public string DrillDownRange { get; set; } = string.Empty; - public int RecordCount { get; set; } - public List<string> ColumnHeaders { get; set; } = new(); -} - -public class SlicerInfo -{ - public string Name { get; set; } = string.Empty; - public string FieldName { get; set; } = string.Empty; - public string SheetName { get; set; } = string.Empty; - public List<string> SelectedItems { get; set; } = new(); -} - -public class SlicerListResult : OperationResult -{ - public List<SlicerInfo> Slicers { get; set; } = new(); -} - -public class PivotCacheInfo -{ - public string SourceData { get; set; } = string.Empty; - public DateTime LastRefresh { get; set; } - public int RecordCount { get; set; } - public List<string> FieldNames { get; set; } = new(); -} - -public class PivotCacheInfoResult : OperationResult -{ - public PivotCacheInfo CacheInfo { get; set; } = new(); -} -``` - ---- - -## Excel COM Implementation Details (VALIDATED) - -### PivotTable Creation Patterns (COM API Verified) - -#### From Excel Range (Most Common) -```csharp -// STEP 1: Validate source data -dynamic sourceSheet = workbook.Worksheets.Item(sourceSheetName); -dynamic sourceRange = sourceSheet.Range[sourceRangeAddress]; - -// Validation checks -if (sourceRange.Rows.Count < 2) - throw new McpException($"Source range must contain headers and at least one data row. Found {sourceRange.Rows.Count} rows"); - -// Check for headers in first row -object[,] headerRow = sourceRange.Rows[1].Value2; -var headers = new List<string>(); -for (int col = 1; col <= headerRow.GetLength(1); col++) -{ - var header = headerRow[1, col]?.ToString(); - if (string.IsNullOrWhiteSpace(header)) - throw new McpException($"Missing header in column {col}. All columns must have headers."); - headers.Add(header); -} - -// STEP 2: Create PivotCache with error handling -dynamic pivotCaches = workbook.PivotCaches(); -dynamic pivotCache; -try -{ - pivotCache = pivotCaches.Create( - SourceType: 1, // xlDatabase = 1 (VALIDATED CONSTANT) - SourceData: $"{sourceSheetName}!{sourceRangeAddress}" - ); -} -catch (Exception ex) -{ - throw new McpException($"Failed to create PivotCache from {sourceSheetName}!{sourceRangeAddress}: {ex.Message}"); -} - -// STEP 3: Create PivotTable with destination validation -dynamic destinationSheet = workbook.Worksheets.Item(destinationSheetName); -dynamic destinationRange = destinationSheet.Range[destinationCell]; - -dynamic pivotTable; -try -{ - pivotTable = pivotCache.CreatePivotTable( - TableDestination: destinationRange, // Must be Range object, not string - TableName: pivotTableName - ); -} -catch (Exception ex) -{ - ComUtilities.Release(ref pivotCache); - throw new McpException($"Failed to create PivotTable '{pivotTableName}' at {destinationSheetName}!{destinationCell}: {ex.Message}"); -} - -// STEP 4: CRITICAL - Refresh to materialize layout -try -{ - pivotTable.RefreshTable(); -} -catch (Exception ex) -{ - throw new McpException($"Failed to refresh PivotTable '{pivotTableName}': {ex.Message}"); -} - -// Return creation result with validation -return new PivotTableCreateResult -{ - Success = true, - PivotTableName = pivotTableName, - SheetName = destinationSheetName, - Range = pivotTable.TableRange2.Address, - SourceData = sourceRangeAddress, - SourceRowCount = sourceRange.Rows.Count - 1, // Exclude headers - AvailableFields = headers, - NumericFields = DetectNumericFields(sourceRange, headers), - TextFields = DetectTextFields(sourceRange, headers), - DateFields = DetectDateFields(sourceRange, headers) -}; -``` - -#### Field Management with COM Constants (VALIDATED) - -```csharp -// EXCEL COM CONSTANTS (from Excel VBA documentation) -public static class XlPivotFieldOrientation -{ - public const int xlHidden = 0; // Field not displayed - public const int xlRowField = 1; // Row area - public const int xlColumnField = 2; // Column area - public const int xlPageField = 3; // Filter area (Page field) - public const int xlDataField = 4; // Values area -} - -public static class XlConsolidationFunction -{ - public const int xlSum = -4157; - public const int xlCount = -4112; - public const int xlAverage = -4106; - public const int xlMax = -4136; - public const int xlMin = -4139; - public const int xlProduct = -4149; - public const int xlCountNums = -4113; - public const int xlStdDev = -4155; - public const int xlStdDevP = -4156; - public const int xlVar = -4164; - public const int xlVarP = -4165; -} - -// Adding field to Row area with validation -public async Task<PivotFieldResult> AddRowFieldAsync(IExcelBatch batch, - string pivotTableName, string fieldName, int? position = null) -{ - return await batch.ExecuteAsync(async (ctx, ct) => - { - // Find PivotTable - dynamic pivotTable = FindPivotTable(ctx.Book, pivotTableName); - - // Validate field exists - dynamic field; - try - { - field = pivotTable.PivotFields.Item(fieldName); - } - catch (Exception) - { - throw new McpException($"Field '{fieldName}' not found in PivotTable '{pivotTableName}'. Available fields: {string.Join(", ", GetFieldNames(pivotTable))}"); - } - - // Check if field is already placed - int currentOrientation = field.Orientation; - if (currentOrientation != XlPivotFieldOrientation.xlHidden) - { - throw new McpException($"Field '{fieldName}' is already placed in {GetAreaName(currentOrientation)} area. Remove it first or use MoveField."); - } - - // Add to Row area - try - { - field.Orientation = XlPivotFieldOrientation.xlRowField; - if (position.HasValue) - { - field.Position = position.Value; - } - } - catch (Exception ex) - { - throw new McpException($"Failed to add field '{fieldName}' to Row area: {ex.Message}"); - } - - // Refresh and validate placement - pivotTable.RefreshTable(); - - // Verify field was added successfully - if (field.Orientation != XlPivotFieldOrientation.xlRowField) - { - throw new McpException($"Field '{fieldName}' was not successfully added to Row area. Current orientation: {GetAreaName(field.Orientation)}"); - } - - // Return detailed result - return new PivotFieldResult - { - Success = true, - FieldName = fieldName, - CustomName = field.Caption?.ToString() ?? fieldName, - Area = PivotFieldArea.Row, - Position = field.Position, - DataType = DetectFieldDataType(field), - AvailableValues = GetFieldUniqueValues(field), - SampleValue = GetFieldSampleValue(field) - }; - }); -} - -// Value field with aggregation function validation -public async Task<PivotFieldResult> AddValueFieldAsync(IExcelBatch batch, - string pivotTableName, string fieldName, - AggregationFunction function = AggregationFunction.Sum, - string? customName = null) -{ - return await batch.ExecuteAsync(async (ctx, ct) => - { - dynamic pivotTable = FindPivotTable(ctx.Book, pivotTableName); - dynamic field = pivotTable.PivotFields.Item(fieldName); - - // Validate aggregation function for field data type - string dataType = DetectFieldDataType(field); - if (!IsValidAggregationForDataType(function, dataType)) - { - var validFunctions = GetValidAggregationsForDataType(dataType); - throw new McpException($"Aggregation function '{function}' is not valid for {dataType} field '{fieldName}'. Valid functions: {string.Join(", ", validFunctions)}"); - } - - // Add to Values area - field.Orientation = XlPivotFieldOrientation.xlDataField; - - // Set aggregation function with COM constant - int comFunction = GetComAggregationFunction(function); - field.Function = comFunction; - - // Set custom name if provided - if (!string.IsNullOrEmpty(customName)) - { - field.Caption = customName; - } - - // Refresh and validate - pivotTable.RefreshTable(); - - // Get sample calculated value for verification - object? sampleValue = GetValueFieldSample(pivotTable, fieldName, function); - - return new PivotFieldResult - { - Success = true, - FieldName = fieldName, - CustomName = field.Caption?.ToString() ?? fieldName, - Area = PivotFieldArea.Value, - Function = function, - DataType = dataType, - SampleValue = sampleValue - }; - }); -} -``` - -### Data Type Detection and Validation - -```csharp -// Field data type detection for validation -private string DetectFieldDataType(dynamic field) -{ - try - { - // Get sample values from field - dynamic pivotItems = field.PivotItems; - var sampleValues = new List<object>(); - - int sampleCount = Math.Min(10, pivotItems.Count); - for (int i = 1; i <= sampleCount; i++) - { - var value = pivotItems.Item(i).Value; - if (value != null) - sampleValues.Add(value); - } - - // Analyze sample values - if (sampleValues.All(v => DateTime.TryParse(v.ToString(), out _))) - return "Date"; - if (sampleValues.All(v => double.TryParse(v.ToString(), out _))) - return "Number"; - if (sampleValues.All(v => bool.TryParse(v.ToString(), out _))) - return "Boolean"; - - return "Text"; - } - catch - { - return "Unknown"; - } -} - -// Aggregation function validation -private bool IsValidAggregationForDataType(AggregationFunction function, string dataType) -{ - return dataType switch - { - "Number" => true, // All functions valid for numbers - "Date" => function == AggregationFunction.Count || function == AggregationFunction.CountNumbers || - function == AggregationFunction.Max || function == AggregationFunction.Min, - "Text" => function == AggregationFunction.Count, - "Boolean" => function == AggregationFunction.Count || function == AggregationFunction.Sum, - _ => function == AggregationFunction.Count - }; -} - -// COM constant mapping with validation -private int GetComAggregationFunction(AggregationFunction function) -{ - return function switch - { - AggregationFunction.Sum => XlConsolidationFunction.xlSum, - AggregationFunction.Count => XlConsolidationFunction.xlCount, - AggregationFunction.Average => XlConsolidationFunction.xlAverage, - AggregationFunction.Max => XlConsolidationFunction.xlMax, - AggregationFunction.Min => XlConsolidationFunction.xlMin, - AggregationFunction.Product => XlConsolidationFunction.xlProduct, - AggregationFunction.CountNumbers => XlConsolidationFunction.xlCountNums, - AggregationFunction.StdDev => XlConsolidationFunction.xlStdDev, - AggregationFunction.StdDevP => XlConsolidationFunction.xlStdDevP, - AggregationFunction.Var => XlConsolidationFunction.xlVar, - AggregationFunction.VarP => XlConsolidationFunction.xlVarP, - _ => throw new McpException($"Unsupported aggregation function: {function}") - }; -} -``` - -### Error Recovery and Cleanup Patterns - -```csharp -// Robust PivotTable creation with cleanup -public async Task<PivotTableCreateResult> CreateFromRangeAsync(IExcelBatch batch, - string sourceSheet, string sourceRange, - string destinationSheet, string destinationCell, - string pivotTableName) -{ - dynamic? pivotCache = null; - dynamic? pivotTable = null; - - try - { - return await batch.ExecuteAsync(async (ctx, ct) => - { - // Validation and creation logic here... - - return result; - }); - } - catch (Exception ex) - { - // Cleanup on failure - if (pivotTable != null) - { - try { pivotTable.Delete(); } catch { } - ComUtilities.Release(ref pivotTable); - } - - if (pivotCache != null) - { - try { pivotCache.Delete(); } catch { } - ComUtilities.Release(ref pivotCache); - } - - throw new McpException($"PivotTable creation failed: {ex.Message}", ex); - } -} -``` - -## Usage Examples - -### Basic PivotTable Creation and Configuration - -```csharp -// Create PivotTable from range -await pivotCommands.CreateFromRangeAsync(batch, "Data", "A1:F1000", "Summary", "A1", "SalesPivot"); - -// Add fields to different areas -await pivotCommands.AddRowFieldAsync(batch, "SalesPivot", "Region"); -await pivotCommands.AddRowFieldAsync(batch, "SalesPivot", "Product"); -await pivotCommands.AddColumnFieldAsync(batch, "SalesPivot", "Year"); -await pivotCommands.AddValueFieldAsync(batch, "SalesPivot", "Sales", AggregationFunction.Sum, "Total Sales"); -await pivotCommands.AddFilterFieldAsync(batch, "SalesPivot", "Category"); - -// Configure formatting -await pivotCommands.SetFieldFormatAsync(batch, "SalesPivot", "Total Sales", "$#,##0"); -await pivotCommands.SetLayoutAsync(batch, "SalesPivot", PivotTableLayout.Tabular); -await pivotCommands.SetStyleAsync(batch, "SalesPivot", "PivotStyleMedium2"); -``` - -### Advanced Analysis - -```csharp -// Group date field by quarters -await pivotCommands.GroupDateFieldAsync(batch, "SalesPivot", "Date", DateGrouping.Quarters); - -// Create calculated field -await pivotCommands.CreateCalculatedFieldAsync(batch, "SalesPivot", "Profit Margin", - "= Profit / Sales * 100"); - -// Sort by sales values (descending) -await pivotCommands.SortFieldByValueAsync(batch, "SalesPivot", "Product", "Total Sales", - SortDirection.Descending); - -// Filter to show only top regions -await pivotCommands.SetFieldFilterAsync(batch, "SalesPivot", "Region", - new List<string> { "North", "South", "East" }); -``` - ---- - -## CLI Commands - -### Phase 1 Commands - -```powershell -# === LIFECYCLE OPERATIONS === -excelcli pivot-list <file.xlsx> -excelcli pivot-info <file.xlsx> <pivot-name> -excelcli pivot-create-from-range <file.xlsx> <src-sheet> <src-range> <dest-sheet> <dest-cell> <pivot-name> -excelcli pivot-create-from-table <file.xlsx> <table-name> <dest-sheet> <dest-cell> <pivot-name> -excelcli pivot-create-from-datamodel <file.xlsx> <connection-name> <dest-sheet> <dest-cell> <pivot-name> -excelcli pivot-delete <file.xlsx> <pivot-name> -excelcli pivot-refresh <file.xlsx> <pivot-name> -excelcli pivot-refresh-all <file.xlsx> - -# === FIELD MANAGEMENT === -excelcli pivot-list-fields <file.xlsx> <pivot-name> -excelcli pivot-add-row-field <file.xlsx> <pivot-name> <field-name> [position] -excelcli pivot-add-column-field <file.xlsx> <pivot-name> <field-name> [position] -excelcli pivot-add-value-field <file.xlsx> <pivot-name> <field-name> [function] [custom-name] -excelcli pivot-add-filter-field <file.xlsx> <pivot-name> <field-name> -excelcli pivot-remove-field <file.xlsx> <pivot-name> <field-name> -excelcli pivot-move-field <file.xlsx> <pivot-name> <field-name> <from-area> <to-area> [position] - -# === FIELD CONFIGURATION === -excelcli pivot-set-field-function <file.xlsx> <pivot-name> <field-name> <function> -excelcli pivot-set-field-name <file.xlsx> <pivot-name> <field-name> <custom-name> -excelcli pivot-set-field-format <file.xlsx> <pivot-name> <field-name> <number-format> - -# === FILTERING AND SORTING === -excelcli pivot-set-field-filter <file.xlsx> <pivot-name> <field-name> <value1,value2,...> -excelcli pivot-clear-field-filter <file.xlsx> <pivot-name> <field-name> -excelcli pivot-get-field-filter <file.xlsx> <pivot-name> <field-name> -excelcli pivot-sort-field <file.xlsx> <pivot-name> <field-name> [asc|desc] -excelcli pivot-sort-field-by-value <file.xlsx> <pivot-name> <sort-field> <value-field> [asc|desc] - -# === LAYOUT AND FORMATTING === -excelcli pivot-set-layout <file.xlsx> <pivot-name> <compact|outline|tabular> -excelcli pivot-set-style <file.xlsx> <pivot-name> <style-name> -excelcli pivot-set-grand-totals <file.xlsx> <pivot-name> <show-row-totals:true|false> <show-column-totals:true|false> -excelcli pivot-set-subtotals <file.xlsx> <pivot-name> <field-name> <show:true|false> -``` - -### Phase 2 Commands - -```powershell -# === GROUPING OPERATIONS === -excelcli pivot-group-date-field <file.xlsx> <pivot-name> <field-name> <years|quarters|months|days> [start-date] [end-date] -excelcli pivot-group-numeric-field <file.xlsx> <pivot-name> <field-name> <start-value> <end-value> <interval> -excelcli pivot-create-custom-group <file.xlsx> <pivot-name> <field-name> <group-name> <item1,item2,...> -excelcli pivot-ungroup-field <file.xlsx> <pivot-name> <field-name> - -# === CALCULATED FIELDS === -excelcli pivot-create-calculated-field <file.xlsx> <pivot-name> <field-name> <formula> -excelcli pivot-update-calculated-field <file.xlsx> <pivot-name> <field-name> <formula> -excelcli pivot-delete-calculated-field <file.xlsx> <pivot-name> <field-name> -excelcli pivot-list-calculated-fields <file.xlsx> <pivot-name> - -# === DRILL DOWN === -excelcli pivot-drill-down <file.xlsx> <pivot-name> <target-sheet> <cell-address> - -# === SLICER INTEGRATION === -excelcli pivot-create-slicer <file.xlsx> <pivot-name> <field-name> <slicer-name> <dest-sheet> <position> -excelcli pivot-list-slicers <file.xlsx> <pivot-name> -excelcli pivot-set-slicer-selection <file.xlsx> <slicer-name> <item1,item2,...> - -# === DATA SOURCE MANAGEMENT === -excelcli pivot-change-data-source <file.xlsx> <pivot-name> <new-sheet> <new-range> -excelcli pivot-get-cache-info <file.xlsx> <pivot-name> -``` - ---- - -## MCP Tool: pivottable - -### Phase 1 Actions - -```typescript -{ - "name": "pivottable", - "description": "Comprehensive Excel PivotTable operations - creation, field management, analysis, and formatting", - "parameters": { - "action": "string", - "excelPath": "string", - "pivotTableName": "string", - "sourceSheet": "string", - "sourceRange": "string", - "tableName": "string", - "connectionName": "string", - "destinationSheet": "string", - "destinationCell": "string", - "fieldName": "string", - "customName": "string", - "function": "string", - "numberFormat": "string", - "selectedValues": "array<string>", - "direction": "string", - "layout": "string", - "styleName": "string", - "showRowTotals": "boolean", - "showColumnTotals": "boolean", - "showSubtotals": "boolean" - }, - "actions": [ - // Lifecycle operations - "list", // List all PivotTables - "get-info", // Get PivotTable details - "create-from-range", // Create from Excel range - "create-from-table", // Create from Excel Table - "create-from-datamodel", // Create from Data Model - "delete", // Delete PivotTable - "refresh", // Refresh data - "refresh-all", // Refresh all PivotTables - - // Field management - "list-fields", // List available fields - "add-row-field", // Add field to Rows area - "add-column-field", // Add field to Columns area - "add-value-field", // Add field to Values area - "add-filter-field", // Add field to Filters area - "remove-field", // Remove field from any area - "move-field", // Move field between areas - - // Field configuration - "set-field-function", // Set aggregation function - "set-field-name", // Set custom field name - "set-field-format", // Set number format - - // Filtering and sorting - "set-field-filter", // Filter field values - "clear-field-filter", // Clear field filter - "get-field-filter", // Get filter state - "sort-field", // Sort field - "sort-field-by-value", // Sort by value field - - // Layout and formatting - "set-layout", // Set PivotTable layout - "set-style", // Apply PivotTable style - "set-grand-totals", // Configure grand totals - "set-subtotals" // Configure subtotals - ] -} -``` - ---- - -## Relationship to Existing Commands - -### Clear Separation of Concerns - -**PivotTableCommands** (New): -- Create, configure, and manage PivotTables -- Field layout and aggregation -- PivotTable-specific filtering and sorting -- PivotCache management - -**TableCommands** (Existing): -- Excel Table (ListObject) operations -- Structured data with headers -- Table filtering and styling -- Can be **data source** for PivotTables - -**RangeCommands** (Existing): -- Direct cell/range data operations -- Can be **data source** for PivotTables -- Can read **output** from PivotTables - -**DataModelCommands** (Existing): -- Power Pivot Data Model -- DAX measures and relationships -- Can be **data source** for PivotTables - -### Workflow Integration - -```csharp -// 1. Create and populate data source (TableCommands) -await tableCommands.CreateAsync(batch, "Data", "SalesTable", "A1:F1000", true); - -// 2. Create PivotTable from table (PivotTableCommands) -await pivotCommands.CreateFromTableAsync(batch, "SalesTable", "Summary", "A1", "SalesPivot"); - -// 3. Configure PivotTable fields (PivotTableCommands) -await pivotCommands.AddRowFieldAsync(batch, "SalesPivot", "Region"); -await pivotCommands.AddValueFieldAsync(batch, "SalesPivot", "Sales", AggregationFunction.Sum); - -// 4. Read PivotTable results (RangeCommands) -var results = await rangeCommands.GetValuesAsync(batch, "Summary", "A1:D20"); -``` - ---- - -## Performance Considerations - -### PivotCache Management - -1. **Reuse Caches**: Multiple PivotTables can share same PivotCache -2. **Refresh Strategy**: Batch refresh operations when possible -3. **Memory Usage**: Large datasets may require cache optimization - -### Field Operations - -1. **Batch Configuration**: Configure multiple fields before refresh -2. **Validation**: Check field existence before operations -3. **Error Handling**: Graceful handling of invalid field configurations - ---- - -## Security Considerations - -### Data Source Access - -1. **Permission Validation**: Ensure user has access to data sources -2. **Connection Security**: Validate external data connections -3. **Range Validation**: Verify source ranges exist and are accessible - ---- - -## Success Criteria - -### Phase 1 (MVP) - ✅ COMPLETE (October 30, 2025) - -**Core Lifecycle Operations:** -- ✅ `List` - List all PivotTables in workbook -- ✅ `Read` - Get complete PivotTable configuration -- ✅ `CreateFromRange` - Create PivotTable from Excel range -- ✅ `CreateFromTable` - Create PivotTable from Excel Table -- ✅ `CreateFromDataModel` - Create PivotTable from Data Model -- ✅ `Delete` - Delete PivotTable -- ✅ `Refresh` - Refresh PivotTable data - -**Field Management:** -- ✅ `ListFields` - List all available fields -- ✅ `AddRowField` - Add field to Row area -- ✅ `AddColumnField` - Add field to Column area -- ✅ `AddValueField` - Add field to Values area (with OLAP measure support) -- ✅ `AddFilterField` - Add field to Filter area -- ✅ `RemoveField` - Remove field from any area - -**Field Configuration:** -- ✅ `SetFieldFunction` - Set aggregation function -- ✅ `SetFieldName` - Set custom field name -- ✅ `SetFieldFormat` - Set number format - -**Analysis Operations:** -- ✅ `GetData` - Read PivotTable data as 2D array for LLM analysis -- ✅ `SetFieldFilter` - Filter field values -- ✅ `SortField` - Sort field ascending/descending - -**Integration:** -- ✅ MCP Server tool (`pivottable` with 25 actions) -- ✅ CLI commands (all 25 operations) -- ✅ Integration tests with comprehensive coverage -- ✅ Workflow guidance and suggested next actions - -### Phase 2 (Advanced) - ✅ PARTIALLY COMPLETE (November 22, 2025) - -**Grouping Operations: 2/4 Complete** -- ✅ `GroupByDate` - Group dates by year/quarter/month/day -- ✅ `GroupByNumeric` - Group numbers by ranges -- ❌ `CreateCustomGroupAsync` - Custom text grouping (NOT IMPLEMENTED) -- ❌ `UngroupFieldAsync` - Remove grouping (NOT IMPLEMENTED) - -**Calculated Fields: 1/4 Complete** -- ✅ `CreateCalculatedField` - Add calculated field with formula -- ❌ `UpdateCalculatedFieldAsync` - Update calculated field formula (NOT IMPLEMENTED) -- ❌ `DeleteCalculatedFieldAsync` - Remove calculated field (NOT IMPLEMENTED) -- ❌ `ListCalculatedFieldsAsync` - List all calculated fields (NOT IMPLEMENTED) - -**Layout & Formatting: 3/3 Complete** -- ✅ `SetLayout` - Set PivotTable layout (Compact/Tabular/Outline) -- ✅ `SetSubtotals` - Show/hide subtotals per field -- ✅ `SetGrandTotals` - Show/hide grand totals (row/column independent) - -**Drill Down: 0/1 Complete** -- ❌ `DrillDownAsync` - Extract source data for specific cell (NOT IMPLEMENTED) - -**Slicer Integration: 4/4 Complete** -- ✅ `CreateSlicer` - Create visual slicer for PivotTable field -- ✅ `ListSlicers` - List slicers with optional filter by PivotTable or sheet -- ✅ `SetSlicerSelection` - Set/clear slicer selection (single, multi, clear all) -- ✅ `DeleteSlicer` - Delete slicer by name - -**Advanced Data Source: 0/2 Complete** -- ❌ `ChangeDataSourceAsync` - Modify PivotCache source (NOT IMPLEMENTED) -- ❌ `GetCacheInfoAsync` - Get PivotCache details (NOT IMPLEMENTED) - -**Phase 2 Summary: 10 of 17 operations implemented (59%)** - ---- - -## Implementation Timeline - -**Phase 1 (Core Operations): ✅ COMPLETE** (October 30, 2025) -- ✅ PivotTable lifecycle and basic field management (19 operations) -- ✅ CLI commands and MCP tool integration -- ✅ Integration tests with comprehensive coverage -- **Actual Time:** ~2 weeks - -**Phase 2a (Advanced Features - Batch 1): ✅ COMPLETE** (November 22, 2025) -- ✅ Grouping (date, numeric), calculated fields, layout, subtotals, grand totals (6 operations) -- ✅ Extended MCP tool actions (25 total) -- ✅ Integration tests for new features -- **Actual Time:** ~3 weeks cumulative - -**Phase 2b (Slicer Integration): ✅ COMPLETE** (January 18, 2025) -- ✅ Slicer integration: CreateSlicer, ListSlicers, SetSlicerSelection, DeleteSlicer (4 operations) -- ✅ New `slicer` MCP tool -- ✅ Integration tests for slicer features (10 tests) -- **Actual Time:** ~1 day - -**Future Enhancements:** ⏸️ DEFERRED -- ⏸️ Drill-down functionality (1 operation) -- ⏸️ Advanced data source management (2 operations) -- ⏸️ Custom grouping and ungroup (2 operations) -- ⏸️ Calculated field CRUD complete (update, delete, list - 3 operations) -- **Estimated Time:** 1-2 weeks when prioritized - -**Total Implementation:** 29 Operations ✅ - Covers 99% of LLM automation use cases - ---- - -## Current Implementation Notes (January 18, 2025) - -### What Works Today (29 Operations) - -**Core Lifecycle & Field Management (19 ops):** -1. ✅ LLM/AI agents can create, configure, and analyze PivotTables through MCP Server -2. ✅ All 19 core operations fully functional via `pivottable` tool -3. ✅ Data extraction via `GetData` returns 2D arrays ready for LLM analysis -4. ✅ Field type detection (numeric, text, date) guides appropriate aggregation functions -5. ✅ Comprehensive error handling with actionable error messages -6. ✅ OLAP/Data Model support with automatic strategy pattern selection - -**Advanced Features (6 ops):** -7. ✅ Date grouping (Years, Quarters, Months, Days) - `GroupByDate` -8. ✅ Numeric grouping with custom intervals - `GroupByNumeric` -9. ✅ Calculated fields with formula support - `CreateCalculatedField` -10. ✅ Layout configuration (Compact, Tabular, Outline) - `SetLayout` -11. ✅ Subtotals control per field - `SetSubtotals` -12. ✅ Grand totals control (row/column independent) - `SetGrandTotals` - -### What's Missing (11 Phase 2b Operations) - -**Low Priority for Automation (7 ops):** -1. ❌ **Slicer management** (3 ops) - Manual workaround: Use Filter fields via `AddFilterField` -2. ❌ **Drill-down** (1 op) - Manual workaround: Use RangeCommands to read source data directly -3. ❌ **Custom text grouping** (1 op) - Manual workaround: Pre-group in source data -4. ❌ **Ungroup fields** (1 op) - Manual workaround: Recreate PivotTable without grouping -5. ❌ **Change data source** (1 op) - Manual workaround: Create new PivotTable with new source - -**Medium Priority for Completeness (4 ops):** -6. ❌ **Update calculated field** - Manual workaround: Delete and recreate -7. ❌ **Delete calculated field** - Manual workaround: Use Excel UI -8. ❌ **List calculated fields** - Manual workaround: Use `ListFields` (shows all fields) -9. ❌ **Get cache info** - Manual workaround: Use `Read` method (returns source data info) - -### Decision Points - -**Phase 2b Priority:** Low - Current 25 operations satisfy 98% of automation scenarios. Remaining Phase 2b features are mainly for advanced interactive Excel use cases or edge cases with easy workarounds. - -**Recommended Next Steps:** -1. ✅ PivotTable API is production-ready for LLM/AI automation -2. 🎯 Focus on Chart feature implementation (similar architecture, high user demand) -3. ⏸️ Phase 2b features: Implement only when specific user requests arise - ---- - -## Open Questions - -1. **Slicer Management**: Should slicers be part of PivotTableCommands or separate SlicerCommands? -2. **Multiple PivotTables**: How to handle operations affecting multiple PivotTables sharing same cache? -3. **Data Model Integration**: Should advanced Data Model PivotTables use TOM API like DataModelCommands? -4. **Chart Integration**: Should PivotChart creation be included or handled separately? - -**Recommended Answers**: -1. **Include in PivotTableCommands** - Slicers are primarily PivotTable features -2. **Individual targeting** - Operations target specific PivotTable, let Excel handle cache sharing -3. **Use Excel COM** - Keep consistent with PivotTable object model, use TOM only for Data Model-specific operations -4. **Separate concern** - PivotCharts could be future ChartCommands (different abstraction level) - ---- - -## MCP Server Implementation (LLM-Optimized Design) - -### ExcelPivotTableTool Design Philosophy - -**LLM-Friendly Actions**: Each action should be intuitive and provide rich context for AI reasoning. - -```csharp -[McpServerTool] -public async Task<string> ExcelPivotTable( - string action, - string excelPath, - string? pivotTableName = null, - string? sourceSheet = null, - string? sourceRange = null, - string? targetSheet = null, - string? targetCell = null, - string? fieldName = null, - string? customName = null, - string? aggregationFunction = null, - object? filterValues = null, - object? sortColumns = null, - string? layoutTemplate = null) -{ - return action.ToLowerInvariant() switch - { - // Core lifecycle (LLM can create, explore, remove) - "create-from-range" => await CreateFromRange(excelPath, sourceSheet!, sourceRange!, targetSheet!, targetCell!, pivotTableName!), - "create-from-table" => await CreateFromTable(excelPath, sourceSheet!, tableOrRangeName!, targetSheet!, targetCell!, pivotTableName!), - "delete" => await DeletePivotTable(excelPath, pivotTableName!), - "list" => ListPivotTables(excelPath), - - // Discovery (LLM can understand structure and guide configuration) - "get-info" => await GetInfo(excelPath, pivotTableName!), - "get-fields" => await GetFields(excelPath, pivotTableName!), - "get-data" => await GetData(excelPath, pivotTableName!), - "get-layout" => await GetLayout(excelPath, pivotTableName!), - - // Field management (LLM can build analysis step-by-step) - "add-row-field" => await AddRowField(excelPath, pivotTableName!, fieldName!), - "add-column-field" => await AddColumnField(excelPath, pivotTableName!, fieldName!), - "add-value-field" => await AddValueField(excelPath, pivotTableName!, fieldName!, aggregationFunction, customName), - "add-page-field" => await AddPageField(excelPath, pivotTableName!, fieldName!), - "remove-field" => await RemoveField(excelPath, pivotTableName!, fieldName!), - "move-field" => await MoveField(excelPath, pivotTableName!, fieldName!, newArea!, newPosition), - - // Data manipulation (LLM can filter and sort interactively) - "set-field-filter" => await SetFieldFilter(excelPath, pivotTableName!, fieldName!, filterValues!), - "clear-field-filter" => await ClearFieldFilter(excelPath, pivotTableName!, fieldName!), - "clear-all-filters" => await ClearAllFilters(excelPath, pivotTableName!), - "sort-field" => await SortField(excelPath, pivotTableName!, fieldName!, sortOrder!), - - // Layout and formatting (LLM can apply templates and styles) - "apply-layout-template" => await ApplyLayoutTemplate(excelPath, pivotTableName!, layoutTemplate!), - "refresh" => await Refresh(excelPath, pivotTableName!), - - _ => ThrowUnknownAction(action, validActions) - }; -} -``` - -### LLM Workflow Examples - -**Scenario 1: LLM creates analysis from scratch** -```typescript -// Step 1: LLM explores available data -pivottable({ - action: "create-from-range", - excelPath: "sales.xlsx", - sourceSheet: "RawData", - sourceRange: "A1:F1000", - targetSheet: "Analysis", - targetCell: "A1", - pivotTableName: "SalesAnalysis" -}) -// Returns: { success: true, availableFields: ["Region", "Product", "Sales", "Date", "Salesperson", "Category"], numericFields: ["Sales"], dateFields: ["Date"] } - -// Step 2: LLM builds row structure -pivottable({ - action: "add-row-field", - excelPath: "sales.xlsx", - pivotTableName: "SalesAnalysis", - fieldName: "Region" -}) -// Returns: { success: true, fieldName: "Region", area: "Row", position: 1, uniqueValues: ["North", "South", "East", "West"] } - -// Step 3: LLM adds analysis dimension -pivottable({ - action: "add-column-field", - excelPath: "sales.xlsx", - pivotTableName: "SalesAnalysis", - fieldName: "Product" -}) -// Returns: { success: true, fieldName: "Product", area: "Column", position: 1, uniqueValues: ["Product A", "Product B", "Product C"] } - -// Step 4: LLM adds metrics -pivottable({ - action: "add-value-field", - excelPath: "sales.xlsx", - pivotTableName: "SalesAnalysis", - fieldName: "Sales", - aggregationFunction: "Sum", - customName: "Total Sales" -}) -// Returns: { success: true, fieldName: "Sales", customName: "Total Sales", function: "Sum", sampleValue: 125000.0 } - -// Step 5: LLM applies filtering for focused analysis -pivottable({ - action: "set-field-filter", - excelPath: "sales.xlsx", - pivotTableName: "SalesAnalysis", - fieldName: "Region", - filterValues: ["North", "South"] -}) -// Returns: { success: true, selectedItems: ["North", "South"], visibleRowCount: 250, totalRowCount: 500 } -``` - -### Rich Result Types for LLM Consumption - -Each action returns detailed information that helps LLMs make informed decisions: - -```csharp -// Create operations return field analysis -public class PivotTableCreateResult : OperationResult -{ - public string PivotTableName { get; set; } = string.Empty; - public string SourceRange { get; set; } = string.Empty; - public string TargetLocation { get; set; } = string.Empty; - public int SourceRowCount { get; set; } - public List<string> AvailableFields { get; set; } = new(); - public List<string> NumericFields { get; set; } = new(); // LLM can suggest Sum, Average - public List<string> DateFields { get; set; } = new(); // LLM can suggest grouping - public List<string> TextFields { get; set; } = new(); // LLM can suggest Count - public Dictionary<string, int> FieldValueCounts { get; set; } = new(); // LLM can assess cardinality -} - -// Field operations return impact analysis -public class PivotFieldResult : OperationResult -{ - public string FieldName { get; set; } = string.Empty; - public PivotFieldArea Area { get; set; } - public int Position { get; set; } - public List<string> UniqueValues { get; set; } = new(); // LLM can understand filter options - public int ValueCount { get; set; } // LLM can assess performance impact - public object? SampleValue { get; set; } // LLM can verify data types - public List<string> SuggestedNextActions { get; set; } = new(); // Guide LLM workflow -} - -// Filter operations return visibility impact -public class PivotFilterResult : OperationResult -{ - public string FieldName { get; set; } = string.Empty; - public List<string> SelectedItems { get; set; } = new(); - public List<string> AvailableItems { get; set; } = new(); - public int VisibleRowCount { get; set; } // LLM can understand filter impact - public int TotalRowCount { get; set; } - public double FilteredPercentage => TotalRowCount > 0 ? (double)VisibleRowCount / TotalRowCount * 100 : 0; -} - -// Data operations return structured analysis results -public class PivotTableDataResult : OperationResult -{ - public string PivotTableName { get; set; } = string.Empty; - public List<string> RowHeaders { get; set; } = new(); - public List<string> ColumnHeaders { get; set; } = new(); - public List<List<object?>> Values { get; set; } = new(); // LLM can analyze patterns - public Dictionary<string, object?> GrandTotals { get; set; } = new(); - public Dictionary<string, object?> RowTotals { get; set; } = new(); - public Dictionary<string, object?> ColumnTotals { get; set; } = new(); - public DateTime LastRefresh { get; set; } - public string DataSummary { get; set; } = string.Empty; // Human-readable summary for LLM -} -``` - -### Error Handling for LLMs - -Provide actionable error messages that help LLMs correct issues: - -```csharp -private async Task<string> AddValueField(string excelPath, string pivotTableName, string fieldName, string? aggregationFunction, string? customName) -{ - try - { - var result = await _commands.AddValueFieldAsync(batch, pivotTableName, fieldName, function, customName); - return JsonSerializer.Serialize(result, JsonOptions); - } - catch (InvalidFieldTypeException ex) - { - // LLM-friendly error with suggestions - var error = new - { - success = false, - error = "invalid_field_type", - message = ex.Message, - fieldName = fieldName, - detectedType = ex.FieldType, - validFunctions = ex.ValidFunctions, // ["Count"] for text fields - suggestion = $"For {ex.FieldType} fields, try using 'Count' instead of '{aggregationFunction}'" - }; - return JsonSerializer.Serialize(error, JsonOptions); - } - catch (FieldNotFoundException ex) - { - var error = new - { - success = false, - error = "field_not_found", - message = ex.Message, - requestedField = fieldName, - availableFields = ex.AvailableFields, - suggestion = ex.AvailableFields.Count > 0 ? $"Did you mean: {ex.AvailableFields.First()}?" : "Check field names with 'get-fields' action" - }; - return JsonSerializer.Serialize(error, JsonOptions); - } -} -``` - -### Layout Templates for LLM Quick Start - -```csharp -public static class PivotLayoutTemplates -{ - public static PivotLayoutTemplate SalesAnalysis => new() - { - Name = "Sales Analysis", - Description = "Region/Product cross-analysis with sales metrics", - RowFields = new[] { "Region" }, - ColumnFields = new[] { "Product" }, - ValueFields = new[] - { - new ValueFieldTemplate("Sales", AggregationFunction.Sum, "Total Sales"), - new ValueFieldTemplate("Sales", AggregationFunction.Count, "Transaction Count") - }, - PageFields = new[] { "Date" }, // For date filtering - DefaultFilters = new Dictionary<string, List<string>>(), - Style = "TableStyleMedium9" - }; - - public static PivotLayoutTemplate TimeSeriesAnalysis => new() - { - Name = "Time Series Analysis", - Description = "Date-based trending with metrics over time", - RowFields = new[] { "Date" }, - ColumnFields = new[] { "Category" }, - ValueFields = new[] - { - new ValueFieldTemplate("Amount", AggregationFunction.Sum, "Total Amount"), - new ValueFieldTemplate("Amount", AggregationFunction.Average, "Average Amount") - }, - GroupDateFields = true, // Group dates by month/quarter - Style = "TableStyleLight16" - }; -} -``` diff --git a/specs/PIVOTTABLE-STRATEGY-PATTERN-REFACTOR.md b/specs/PIVOTTABLE-STRATEGY-PATTERN-REFACTOR.md deleted file mode 100644 index 096b1688..00000000 --- a/specs/PIVOTTABLE-STRATEGY-PATTERN-REFACTOR.md +++ /dev/null @@ -1,108 +0,0 @@ -# PivotTable Strategy Pattern Refactor - Implementation Spec - -## Current Problem -- Single `GetFieldForManipulation()` returns different types (CubeField vs PivotField) based on runtime detection -- `isOlap` flag scattered across 10+ methods with conditional logic -- Tests passing claimed in PR #162 but only 1/11 OLAP tests actually work -- Difficult to maintain and debug - -## Proposed Solution: Strategy Pattern - -### Architecture - -``` -IPivotTableFieldStrategy (interface) -├── RegularPivotTableFieldStrategy -│ ├── CanHandle() - checks pivot.PivotFields exists -│ ├── GetFieldForManipulation() - returns PivotField -│ └── AddRow/Column/Value/Filter/Remove/Set* methods -└── OlapPivotTableFieldStrategy - ├── CanHandle() - checks pivot.CubeFields.Count > 0 - ├── GetFieldForManipulation() - returns CubeField + CreatePivotFields() - └── AddRow/Column/Value/Filter/Remove/Set* methods (CubeField-specific) - -PivotTableFieldStrategyFactory -└── GetStrategy(pivot) - selects OlapStrategy or RegularStrategy -``` - -### File Structure - -``` -src/ExcelMcp.Core/Commands/PivotTable/ -├── IPivotTableFieldStrategy.cs (NEW) ✅ CREATED -├── PivotTableFieldStrategyFactory.cs (NEW) ✅ CREATED -├── RegularPivotTableFieldStrategy.cs (NEW) - Extract from current code -├── OlapPivotTableFieldStrategy.cs (NEW) - OLAP-specific implementation -├── PivotTableCommands.cs (KEEP) - FindPivotTable, helpers -└── PivotTableCommands.Fields.cs (REFACTOR) - Delegate to strategies -``` - -### Implementation Steps - -1. **RegularPivotTableFieldStrategy** - Extract existing logic - - Copy current PivotTableCommands.Fields.cs methods - - Remove all `isOlap` conditionals (keep regular path only) - - Returns PivotField from GetFieldForManipulation - - Uses pivot.PivotFields API exclusively - -2. **OlapPivotTableFieldStrategy** - New OLAP implementation - - Returns CubeField from GetFieldForManipulation - - Calls CreatePivotFields() when PivotFields don't exist - - Sets Orientation on CubeField (not PivotField!) - - Uses pivot.CubeFields API exclusively - - Skip data type detection (return "Cube") - - Skip available values enumeration (not supported) - -3. **Refactor PivotTableCommands.Fields.cs** - - Each method becomes thin wrapper: - ```csharp - public async Task<PivotFieldResult> AddRowFieldAsync(...) - { - return await batch.Execute((ctx, ct) => - { - var pivot = FindPivotTable(ctx.Book, pivotTableName); - var strategy = PivotTableFieldStrategyFactory.GetStrategy(pivot); - return strategy.AddRowField(pivot, fieldName, position, batch.WorkbookPath); - }); - } - ``` - - Remove GetFieldForManipulation from PivotTableCommands.cs (move to strategies) - - Keep shared helpers: FindPivotTable, GetAreaName, GetComAggregationFunction, etc. - -### Testing Strategy - -**Existing Tests (Keep As-Is):** -- PivotTableCommandsTests.Fields.cs (28 tests) - Regular PivotTables -- PivotTableCommandsTests.OlapFields.cs (11 tests) - OLAP PivotTables - -**Expected Results After Refactor:** -- Regular tests: 28/28 passing (unchanged behavior) -- OLAP tests: 11/11 passing (fix via OlapStrategy) - -### Benefits - -1. **Separation of Concerns** - OLAP logic isolated from Regular logic -2. **Type Safety** - Each strategy knows exactly what it returns -3. **Testability** - Can unit test each strategy independently -4. **Maintainability** - No more scattered `isOlap` conditionals -5. **Extensibility** - Easy to add new PivotTable types (PowerBI, SQL, etc.) -6. **SOLID Principles** - Single Responsibility, Open/Closed - -### Risks & Mitigation - -**Risk:** Large refactor might break existing tests -**Mitigation:** Implement RegularStrategy first, verify 28 tests pass, then OLAP - -**Risk:** COM lifecycle issues with different object types -**Mitigation:** Each strategy owns its COM cleanup logic - -**Risk:** Time to implement -**Mitigation:** ~2-3 hours for complete implementation + testing - -### Success Criteria - -- ✅ All 28 regular PivotTable tests pass -- ✅ All 11 OLAP PivotTable tests pass -- ✅ Build with 0 warnings -- ✅ No `isOlap` conditionals in PivotTableCommands.Fields.cs -- ✅ Clear separation between Regular and OLAP logic diff --git a/specs/RANGE-API-SPECIFICATION.md b/specs/RANGE-API-SPECIFICATION.md deleted file mode 100644 index df43510b..00000000 --- a/specs/RANGE-API-SPECIFICATION.md +++ /dev/null @@ -1,1871 +0,0 @@ -# Excel Range API Specification - -> **Comprehensive range operations for ExcelMcp - replacing fragmented cell/sheet operations** - -## Executive Summary - -This specification defines a unified **Range API** that consolidates and replaces fragmented cell and partial sheet operations with a comprehensive, performance-optimized approach to working with Excel ranges. - -### Key Design Decisions - -1. **Single Cell = Range** - A single cell (e.g., "A1") is a 1x1 range; no separate "cell" API needed -2. **All Operations Use 2D Arrays** - Consistent interface whether operating on one cell or 10,000 cells -3. **COM-Backed Only** - Every operation uses native Excel COM (no data processing in server) -4. **CSV is CLI-Only** - Core/MCP use 2D arrays; CLI converts CSV for user convenience - -### Goals - -1. **Unify operations** - Single consistent API for single cells, ranges, entire columns/rows -2. **Performance** - Bulk operations using Excel COM's native 2D array support -3. **Replace fragmentation** - Eliminate duplication between CellCommands and SheetCommands -4. **Excel parity** - Support operations users expect from Excel UI -5. **Type safety** - Proper handling of values vs formulas vs formats - ---- - -## Current State Analysis - -### Existing Functionality (Fragmented) - -**CellCommands** (Single cells only): -- ✅ GetValueAsync - Read single cell value -- ✅ SetValueAsync - Write single cell value -- ✅ GetFormulaAsync - Read single cell formula -- ✅ SetFormulaAsync - Write single cell formula -- ❌ No formatting support -- ❌ No multi-cell support - -**SheetCommands** (Worksheet-level only): -- ✅ ReadAsync - Read range data (values only) -- ✅ WriteAsync - Write CSV data to range -- ✅ ClearAsync - Clear range (values + formulas) -- ❌ No formula read/write -- ❌ No formatting support -- ❌ Inconsistent with Cell API - -**HyperlinkCommands** (Exists, needs integration): -- ✅ AddHyperlinkAsync - Add hyperlink to cell/range -- ✅ RemoveHyperlinkAsync - Remove hyperlink -- ✅ ListHyperlinksAsync - List all hyperlinks in sheet -- ✅ GetHyperlinkAsync - Get hyperlink from cell -- ⚠️ Operates on ranges but separate API - -**TableCommands** (Structured data, separate concern): -- ✅ Excel Table (ListObject) operations -- ✅ Styling, totals, data model integration -- ✅ Should remain separate (different abstraction level) - -**NamedRangeCommands** (Named ranges, separate concern): -- ✅ Create, delete, update named range definitions -- ✅ List all named ranges -- ✅ Get/set single values (parameters) -- ✅ Should remain separate (named range lifecycle management) -- ⚠️ RangeCommands will ADD bulk read/write to named ranges (data operations) - -### Problems with Current State - -1. **API Confusion**: Cell vs Sheet operations overlap -2. **Performance**: Single-cell operations inefficient for bulk work -3. **Incomplete**: No formatting, no data validation, limited hyperlink integration -4. **Inconsistency**: Different patterns for similar operations - ---- - -## Research: Excel Range Operations - -### Core Operations (Must Have) - -#### 1. **Value Operations** -```csharp -// Excel COM: Range.Value2 property (variant array) -range.Value2 = values; // Bulk write -object[,] data = range.Value2; // Bulk read -``` - -#### 2. **Formula Operations** -```csharp -// Excel COM: Range.Formula property (string array) -range.Formula = formulas; // Bulk write -object[,] formulas = range.Formula; // Bulk read -``` - -#### 3. **Clearing** -```csharp -// Excel COM: Range.Clear() and variants -range.Clear(); // All content -range.ClearContents(); // Values/formulas only -range.ClearFormats(); // Formatting only -range.ClearComments(); // Comments only -range.ClearHyperlinks(); // Hyperlinks only -``` - -#### 4. **Copy/Paste** -```csharp -// Excel COM: Range.Copy() and Range.PasteSpecial() -sourceRange.Copy(destinationRange); // Copy all -destinationRange.PasteSpecial(xlPasteValues); // Paste values only -destinationRange.PasteSpecial(xlPasteFormulas); // Paste formulas -destinationRange.PasteSpecial(xlPasteFormats); // Paste formats -``` - -### Formatting Operations (Should Have) - -#### 5. **Number Formatting** -```csharp -// Excel COM: Range.NumberFormat property -range.NumberFormat = "#,##0.00"; // Currency -range.NumberFormat = "0.00%"; // Percentage -range.NumberFormat = "m/d/yyyy"; // Date -range.NumberFormat = "@"; // Text -``` - -#### 6. **Font Formatting** -```csharp -// Excel COM: Range.Font object -range.Font.Name = "Arial"; -range.Font.Size = 12; -range.Font.Bold = true; -range.Font.Italic = true; -range.Font.Color = RGB(255, 0, 0); // Red -``` - -#### 7. **Cell Formatting** -```csharp -// Excel COM: Range.Interior (background) -range.Interior.Color = RGB(255, 255, 0); // Yellow background -range.Interior.Pattern = xlSolid; - -// Range.Borders (borders) -range.Borders.LineStyle = xlContinuous; -range.Borders.Weight = xlMedium; -range.Borders.Color = RGB(0, 0, 0); -``` - -#### 8. **Alignment** -```csharp -// Excel COM: Range alignment properties -range.HorizontalAlignment = xlCenter; -range.VerticalAlignment = xlCenter; -range.WrapText = true; -range.Orientation = 45; // Rotate text -``` - -### Smart Range Operations (Native Excel COM) - -#### 9. **UsedRange** -```csharp -// Excel COM: Worksheet.UsedRange property -dynamic usedRange = sheet.UsedRange; -string address = usedRange.Address; // e.g., "$A$1:$D$100" -object[,] values = usedRange.Value2; // All non-empty data -``` - -#### 10. **CurrentRegion** -```csharp -// Excel COM: Range.CurrentRegion property -dynamic region = range.CurrentRegion; -string address = region.Address; // Contiguous block around cell -object[,] values = region.Value2; -``` - -#### 11. **Range Properties** -```csharp -// Excel COM: Range information properties -string address = range.Address; // Absolute address "$A$1:$D$10" -int rowCount = range.Rows.Count; // Number of rows -int columnCount = range.Columns.Count; // Number of columns -string numberFormat = range.NumberFormat; // Format code -``` - -#### 12. **Named Ranges** -```csharp -// Excel COM: Workbook.Names collection -dynamic namedRange = workbook.Names.Item("SalesData").RefersToRange; -object[,] values = namedRange.Value2; // Read from named range -namedRange.Value2 = newValues; // Write to named range -``` - -#### 13. **Insert/Delete** -```csharp -// Excel COM: Range.Insert() and Range.Delete() -range.Insert(xlShiftDown); // Insert cells, shift down -range.Insert(xlShiftToRight); // Insert cells, shift right -range.Delete(xlShiftUp); // Delete cells, shift up -range.Delete(xlShiftToLeft); // Delete cells, shift left - -// Entire rows/columns -range.EntireRow.Insert(); // Insert entire rows -range.EntireRow.Delete(); // Delete entire rows -range.EntireColumn.Insert(); // Insert entire columns -range.EntireColumn.Delete(); // Delete entire columns -``` - -#### 14. **Find/Replace** -```csharp -// Excel COM: Range.Find() and Range.Replace() -dynamic foundCell = range.Find( - What: "searchText", - LookIn: xlValues, // or xlFormulas - LookAt: xlWhole, // or xlPart - MatchCase: false -); - -range.Replace( - What: "oldText", - Replacement: "newText", - LookAt: xlPart, - MatchCase: false -); -``` - -#### 15. **Sort** -```csharp -// Excel COM: Range.Sort() -range.Sort( - Key1: range.Columns[1], // First sort column - Order1: xlAscending, - Key2: range.Columns[2], // Second sort column - Order2: xlDescending, - Header: xlYes // Has headers -); -``` - -### Advanced Operations (Could Have) - -#### 9. **Data Validation** -```csharp -// Excel COM: Range.Validation -range.Validation.Add(xlValidateList, xlValidAlertStop, xlBetween, "Item1,Item2,Item3"); -range.Validation.Delete(); -``` - -#### 10. **Conditional Formatting** -```csharp -// Excel COM: Range.FormatConditions -range.FormatConditions.Add(xlCellValue, xlGreater, "100"); -range.FormatConditions(1).Interior.Color = RGB(255, 0, 0); -``` - -#### 11. **Merge/Unmerge** -```csharp -// Excel COM: Range.Merge/UnMerge -range.Merge(); -range.UnMerge(); -range.MergeCells; // Property to check -``` - -#### 12. **Auto-Resize** -```csharp -// Excel COM: Range.AutoFit -range.Columns.AutoFit(); // Auto-size columns -range.Rows.AutoFit(); // Auto-size rows -``` - ---- - -## Proposed Range API Design - -> **⚠️ IMPORTANT: All operations are backed by native Excel COM API** -> **CSV Import/Export**: CLI-only feature (not in Core or MCP Server) -> **Single Cell = Range**: A single cell (e.g., "A1") is treated as a 1x1 range - -### Design Principles - -1. **COM-Backed Only**: Every method uses native Excel COM operations -2. **No Data Processing**: Server doesn't transform data (transpose, statistics, etc.) - LLMs do that -3. **2D Arrays in Core**: Core uses `List<List<object?>>` (native C# representation) -4. **CSV in CLI Only**: CLI handles CSV ↔ 2D array conversion for user convenience -5. **JSON in MCP**: MCP Server serializes 2D arrays to JSON for protocol -6. **Single Cell = Range**: All operations work on ranges; single cells are 1x1 ranges (e.g., "A1" returns `[[value]]`) - -### Phase 1: Core Operations (MVP) - -#### IRangeCommands Interface - -```csharp -public interface IRangeCommands -{ - // === VALUE OPERATIONS === - - /// <summary> - /// Gets values from a range as 2D array - /// Single cell "A1" returns [[value]], range "A1:B2" returns [[v1,v2],[v3,v4]] - /// </summary> - Task<RangeValueResult> GetValuesAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - /// <summary> - /// Sets values in a range from 2D array - /// Single cell "A1" accepts [[value]], range "A1:B2" accepts [[v1,v2],[v3,v4]] - /// </summary> - Task<OperationResult> SetValuesAsync(IExcelBatch batch, string sheetName, string rangeAddress, List<List<object?>> values); - - // === FORMULA OPERATIONS === - - /// <summary> - /// Gets formulas from a range as 2D array (empty string if no formula) - /// Single cell "A1" returns [["=SUM(B:B)"]], range "A1:B2" returns [[f1,f2],[f3,f4]] - /// </summary> - Task<RangeFormulaResult> GetFormulasAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - /// <summary> - /// Sets formulas in a range from 2D array - /// </summary> - Task<OperationResult> SetFormulasAsync(IExcelBatch batch, string sheetName, string rangeAddress, List<List<string>> formulas); - - // === CLEAR OPERATIONS === - - /// <summary> - /// Clears all content (values, formulas, formats) from range - /// </summary> - Task<OperationResult> ClearAllAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - /// <summary> - /// Clears only values and formulas (preserves formatting) - /// </summary> - Task<OperationResult> ClearContentsAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - /// <summary> - /// Clears only formatting (preserves values and formulas) - /// </summary> - Task<OperationResult> ClearFormatsAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - // === COPY OPERATIONS === - - /// <summary> - /// Copies range to another location (all content) - /// </summary> - Task<OperationResult> CopyAsync(IExcelBatch batch, string sourceSheet, string sourceRange, string targetSheet, string targetRange); - - /// <summary> - /// Copies only values (no formulas or formatting) - /// </summary> - Task<OperationResult> CopyValuesAsync(IExcelBatch batch, string sourceSheet, string sourceRange, string targetSheet, string targetRange); - - /// <summary> - /// Copies only formulas (no values or formatting) - /// </summary> - Task<OperationResult> CopyFormulasAsync(IExcelBatch batch, string sourceSheet, string sourceRange, string targetSheet, string targetRange); - - // === INSERT/DELETE OPERATIONS === (⭐ POWER USER ESSENTIAL) - - /// <summary> - /// Inserts blank cells, shifting existing cells down or right - /// </summary> - Task<OperationResult> InsertCellsAsync(IExcelBatch batch, string sheetName, string rangeAddress, InsertShiftDirection shift); - - /// <summary> - /// Deletes cells, shifting remaining cells up or left - /// </summary> - Task<OperationResult> DeleteCellsAsync(IExcelBatch batch, string sheetName, string rangeAddress, DeleteShiftDirection shift); - - /// <summary> - /// Inserts entire rows above the range - /// </summary> - Task<OperationResult> InsertRowsAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - /// <summary> - /// Deletes entire rows in the range - /// </summary> - Task<OperationResult> DeleteRowsAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - /// <summary> - /// Inserts entire columns to the left of the range - /// </summary> - Task<OperationResult> InsertColumnsAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - /// <summary> - /// Deletes entire columns in the range - /// </summary> - Task<OperationResult> DeleteColumnsAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - // === FIND/REPLACE OPERATIONS === (⭐ POWER USER ESSENTIAL) - - /// <summary> - /// Finds all cells matching criteria in range - /// </summary> - Task<RangeFindResult> FindAsync(IExcelBatch batch, string sheetName, string rangeAddress, string searchValue, FindOptions options); - - /// <summary> - /// Replaces text/values in range - /// </summary> - Task<OperationResult> ReplaceAsync(IExcelBatch batch, string sheetName, string rangeAddress, string findValue, string replaceValue, ReplaceOptions options); - - // === SORT OPERATIONS === (⭐ POWER USER ESSENTIAL) - - /// <summary> - /// Sorts range by one or more columns - /// </summary> - Task<OperationResult> SortAsync(IExcelBatch batch, string sheetName, string rangeAddress, List<SortColumn> sortColumns, bool hasHeaders = true); - - // === NATIVE EXCEL COM OPERATIONS === (⭐ LLM/AI AGENT ESSENTIAL) - - /// <summary> - /// Gets the used range (all non-empty cells) from worksheet - /// Excel COM: Worksheet.UsedRange - /// </summary> - Task<RangeValueResult> GetUsedRangeAsync(IExcelBatch batch, string sheetName); - - /// <summary> - /// Gets the current region (contiguous data block) around a cell - /// Excel COM: Range.CurrentRegion - /// </summary> - Task<RangeValueResult> GetCurrentRegionAsync(IExcelBatch batch, string sheetName, string cellAddress); - - /// <summary> - /// Gets range information (address, dimensions, number formats) - /// Excel COM: Range.Address, Range.Rows.Count, Range.Columns.Count, Range.NumberFormat - /// </summary> - Task<RangeInfoResult> GetRangeInfoAsync(IExcelBatch batch, string sheetName, string rangeAddress); -} - -// === SUPPORTING TYPES FOR PHASE 1 === - -public enum InsertShiftDirection { Down, Right } -public enum DeleteShiftDirection { Up, Left } - -public class FindOptions -{ - public bool MatchCase { get; set; } = false; - public bool MatchEntireCell { get; set; } = false; - public bool SearchFormulas { get; set; } = true; - public bool SearchValues { get; set; } = true; - public bool SearchComments { get; set; } = false; -} - -public class ReplaceOptions : FindOptions -{ - public bool ReplaceAll { get; set; } = true; -} - -public class SortColumn -{ - public int ColumnIndex { get; set; } // 1-based within range - public bool Ascending { get; set; } = true; -} - -public class RangeFindResult : OperationResult -{ - public List<RangeCell> MatchingCells { get; set; } = new(); -} - -public class RangeCell -{ - public string Address { get; set; } = string.Empty; // e.g., "A5" - public int Row { get; set; } - public int Column { get; set; } - public object? Value { get; set; } -} - -public class RangeInfoResult : OperationResult -{ - public string Address { get; set; } = string.Empty; // Absolute address from Excel COM - public int RowCount { get; set; } // Excel COM: range.Rows.Count - public int ColumnCount { get; set; } // Excel COM: range.Columns.Count - public string? NumberFormat { get; set; } // Excel COM: range.NumberFormat (first cell) -} -``` - -### Phase 2: Number Formatting - -```csharp -public interface IRangeCommands -{ - // === NUMBER FORMAT OPERATIONS === - - /// <summary> - /// Gets number format codes from range - /// </summary> - Task<RangeFormatResult> GetNumberFormatsAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - /// <summary> - /// Sets number format for entire range - /// </summary> - Task<OperationResult> SetNumberFormatAsync(IExcelBatch batch, string sheetName, string rangeAddress, string formatCode); - - /// <summary> - /// Sets number formats from 2D array (cell-by-cell) - /// </summary> - Task<OperationResult> SetNumberFormatsAsync(IExcelBatch batch, string sheetName, string rangeAddress, List<List<string>> formats); -} -``` - -### Phase 3: Visual Formatting - -```csharp -public interface IRangeCommands -{ - // === FONT OPERATIONS === - - /// <summary> - /// Sets font properties for range - /// </summary> - Task<OperationResult> SetFontAsync(IExcelBatch batch, string sheetName, string rangeAddress, FontOptions font); - - // === CELL APPEARANCE === - - /// <summary> - /// Sets background color for range - /// </summary> - Task<OperationResult> SetBackgroundColorAsync(IExcelBatch batch, string sheetName, string rangeAddress, int color); - - /// <summary> - /// Sets borders for range - /// </summary> - Task<OperationResult> SetBordersAsync(IExcelBatch batch, string sheetName, string rangeAddress, BorderOptions borders); - - /// <summary> - /// Sets alignment for range - /// </summary> - Task<OperationResult> SetAlignmentAsync(IExcelBatch batch, string sheetName, string rangeAddress, AlignmentOptions alignment); -} - -public class FontOptions -{ - public string? Name { get; set; } - public int? Size { get; set; } - public bool? Bold { get; set; } - public bool? Italic { get; set; } - public int? Color { get; set; } // RGB color -} - -public class BorderOptions -{ - public string LineStyle { get; set; } = "continuous"; // continuous, dashed, dotted, none - public string Weight { get; set; } = "thin"; // thin, medium, thick - public int? Color { get; set; } -} - -public class AlignmentOptions -{ - public string? Horizontal { get; set; } // left, center, right, justify - public string? Vertical { get; set; } // top, middle, bottom - public bool? WrapText { get; set; } -} -``` - -### Phase 4: Advanced Features - -```csharp -public interface IRangeCommands -{ - // === COMMENTS/NOTES === (⭐ POWER USER ESSENTIAL) - - /// <summary> - /// Adds comment to a cell - /// </summary> - Task<OperationResult> AddCommentAsync(IExcelBatch batch, string sheetName, string cellAddress, string commentText, string? author = null); - - /// <summary> - /// Gets all comments in range - /// </summary> - Task<RangeCommentsResult> GetCommentsAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - /// <summary> - /// Deletes comment from a cell - /// </summary> - Task<OperationResult> DeleteCommentAsync(IExcelBatch batch, string sheetName, string cellAddress); - - // === PROTECTION === (⭐ POWER USER ESSENTIAL) - - /// <summary> - /// Locks cells (prevents editing when sheet is protected) - /// </summary> - Task<OperationResult> LockCellsAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - /// <summary> - /// Unlocks cells (allows editing even when sheet is protected) - /// </summary> - Task<OperationResult> UnlockCellsAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - /// <summary> - /// Gets locked status of cells in range - /// </summary> - Task<RangeLockResult> GetLockedStatusAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - // === GROUPING/OUTLINE === (⭐ POWER USER ESSENTIAL) - - /// <summary> - /// Groups rows (creates outline) - /// </summary> - Task<OperationResult> GroupRowsAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - /// <summary> - /// Ungroups rows (removes outline) - /// </summary> - Task<OperationResult> UngroupRowsAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - /// <summary> - /// Groups columns (creates outline) - /// </summary> - Task<OperationResult> GroupColumnsAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - /// <summary> - /// Ungroups columns (removes outline) - /// </summary> - Task<OperationResult> UngroupColumnsAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - // === DATA VALIDATION === - - /// <summary> - /// Adds data validation to range - /// </summary> - Task<OperationResult> AddValidationAsync(IExcelBatch batch, string sheetName, string rangeAddress, ValidationOptions validation); - - /// <summary> - /// Removes data validation from range - /// </summary> - Task<OperationResult> RemoveValidationAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - // === MERGE OPERATIONS === - - /// <summary> - /// Merges cells in range - /// </summary> - Task<OperationResult> MergeCellsAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - /// <summary> - /// Unmerges cells in range - /// </summary> - Task<OperationResult> UnmergeCellsAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - // === AUTO-RESIZE === - - /// <summary> - /// Auto-fits column widths to content - /// </summary> - Task<OperationResult> AutoFitColumnsAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - /// <summary> - /// Auto-fits row heights to content - /// </summary> - Task<OperationResult> AutoFitRowsAsync(IExcelBatch batch, string sheetName, string rangeAddress); -} - -// === SUPPORTING TYPES FOR PHASE 4 === - -public class RangeCommentsResult : OperationResult -{ - public List<CellComment> Comments { get; set; } = new(); -} - -public class CellComment -{ - public string CellAddress { get; set; } = string.Empty; - public string Text { get; set; } = string.Empty; - public string? Author { get; set; } -} - -public class RangeLockResult : OperationResult -{ - public List<List<bool>> LockedStatus { get; set; } = new(); // 2D array matching range -} - -public class ValidationOptions -{ - public string Type { get; set; } = "list"; // list, whole, decimal, date, time, text-length, custom - public string Operator { get; set; } = "between"; // between, not-between, equal, not-equal, greater, less, greater-or-equal, less-or-equal - public string? Formula1 { get; set; } // First condition or list items - public string? Formula2 { get; set; } // Second condition (for between) - public string? ErrorTitle { get; set; } - public string? ErrorMessage { get; set; } -} -``` - ---- - -## Hyperlink Integration - -**Decision**: Integrate hyperlinks directly into RangeCommands (DELETE HyperlinkCommands): - -```csharp -public interface IRangeCommands -{ - // === HYPERLINK OPERATIONS === - - /// <summary> - /// Adds hyperlink to a single cell - /// </summary> - Task<OperationResult> AddHyperlinkAsync(IExcelBatch batch, string sheetName, string cellAddress, string url, string? displayText = null, string? tooltip = null); - - /// <summary> - /// Removes hyperlink from a single cell or all hyperlinks from a range - /// </summary> - Task<OperationResult> RemoveHyperlinkAsync(IExcelBatch batch, string sheetName, string rangeAddress); - - /// <summary> - /// Lists all hyperlinks in a worksheet - /// </summary> - Task<RangeHyperlinkResult> ListHyperlinksAsync(IExcelBatch batch, string sheetName); - - /// <summary> - /// Gets hyperlink from a specific cell - /// </summary> - Task<RangeHyperlinkResult> GetHyperlinkAsync(IExcelBatch batch, string sheetName, string cellAddress); -} -``` - -**Rationale**: -- Hyperlinks are just another property of a range/cell (like formulas or formatting) -- No need for separate command class -- Simpler API for users -- Consistent with Range-centric design - ---- - -## Named Ranges Integration - -### Unified Range Address Design - -**Key Insight**: Named ranges are just **aliases for cell addresses**. RangeCommands should accept both seamlessly. - -### Division of Responsibilities - -**NamedRangeCommands** (Existing - KEEP): -- **Define** named ranges: `CreateAsync("SalesData", "Sheet1!A1:D100")` -- **Manage** named ranges: `UpdateAsync`, `DeleteAsync`, `ListAsync` -- **Get/Set single value**: `GetAsync`, `SetAsync` (treats named range as parameter/scalar) -- **Purpose**: Named range lifecycle and metadata management - -**RangeCommands** (New - Unified Approach): -- **Accepts BOTH** `"Sheet1!A1:D100"` AND `"SalesData"` in rangeAddress parameter -- **Automatic resolution**: If rangeAddress is a named range, resolve to actual address internally -- **No separate methods needed**: `GetValuesAsync` works for both regular ranges and named ranges - -### How It Works - -```csharp -// STEP 1: Define the named range (NamedRangeCommands) -await NamedRangeCommands.CreateAsync(batch, "SalesData", "Sheet1!A1:D100"); - -// STEP 2: Write data - rangeAddress accepts BOTH formats -await rangeCommands.SetValuesAsync(batch, "", "SalesData", salesData); // Named range -await rangeCommands.SetValuesAsync(batch, "Sheet1", "A1:D100", salesData); // Regular range -// Both work identically! - -// STEP 3: Read data - rangeAddress accepts BOTH formats -var result1 = await rangeCommands.GetValuesAsync(batch, "", "SalesData"); // Named range -var result2 = await rangeCommands.GetValuesAsync(batch, "Sheet1", "A1:D100"); // Regular range -// Both return same data! - -// STEP 4: Update the named range reference (NamedRangeCommands) -await NamedRangeCommands.UpdateAsync(batch, "SalesData", "Sheet1!A1:D200"); // Expand range -``` - -### Implementation Strategy - -```csharp -public async Task<RangeValueResult> GetValuesAsync(IExcelBatch batch, string sheetName, string rangeAddress) -{ - return await batch.ExecuteAsync(async (ctx, ct) => - { - dynamic range; - - // Try to resolve as named range first - if (string.IsNullOrEmpty(sheetName)) - { - try - { - dynamic name = ctx.Book.Names.Item(rangeAddress); - range = name.RefersToRange; // Resolve named range to actual range - } - catch - { - throw new McpException($"Named range '{rangeAddress}' not found"); - } - } - else - { - // Regular sheet!range address - dynamic sheet = ctx.Book.Worksheets.Item(sheetName); - range = sheet.Range[rangeAddress]; - } - - // Rest of implementation identical for both paths - object[,] values = range.Value2; - // ... - }); -} -``` - -### API Comparison - -| Operation | NamedRangeCommands | RangeCommands | -|-----------|------------------|---------------| -| **Create named range** | ✅ `CreateAsync` | ❌ | -| **Delete named range** | ✅ `DeleteAsync` | ❌ | -| **Update reference** | ✅ `UpdateAsync` | ❌ | -| **List all names** | ✅ `ListAsync` | ❌ | -| **Get single value** | ✅ `GetAsync` → scalar | ✅ `GetValuesAsync("", "Name")` → `[[value]]` | -| **Set single value** | ✅ `SetAsync` → scalar | ✅ `SetValuesAsync("", "Name", [[value]])` | -| **Get bulk data** | ❌ | ✅ `GetValuesAsync("", "Name")` → 2D array | -| **Set bulk data** | ❌ | ✅ `SetValuesAsync("", "Name", values)` | - -### When to Use Which - -**Use NamedRangeCommands when**: -- Defining/managing named range lifecycle (create, delete, update reference) -- Listing all named ranges in workbook -- Working with named ranges as single-value parameters (scalar get/set) - -**Use RangeCommands when**: -- Reading/writing data (works with both named ranges and regular ranges) -- Don't need to know if it's a named range or not -- Want unified API for all range operations - -### LLM/MCP Perspective - -**As an LLM, I just want to read data - I don't care about the addressing scheme**: - -```typescript -// I can use either - RangeCommands handles both -range({ action: "get-values", sheetName: "", rangeAddress: "SalesData" }) -range({ action: "get-values", sheetName: "Sheet1", rangeAddress: "A1:D100" }) - -// No need for separate "get-named-range-values" action! -``` - -### Excel COM Mapping - -```csharp -// Named range resolution (automatic in RangeCommands) -dynamic name = workbook.Names.Item("SalesData"); -dynamic range = name.RefersToRange; // Returns Range object -object[,] values = range.Value2; // Same as regular range! - -// Regular range (same final operation) -dynamic sheet = workbook.Worksheets.Item("Sheet1"); -dynamic range = sheet.Range["A1:D100"]; -object[,] values = range.Value2; // Identical to named range! -``` - -**Key**: Both paths produce a `Range` COM object, so all operations are identical after resolution. - ---- - -## Migration Strategy - -### ⚠️ BREAKING CHANGES - No Backwards Compatibility Required - -Since backwards compatibility is not required, we can make clean architectural decisions: - -### Removal Plan - -1. **CellCommands** → **DELETE ENTIRELY** - - ❌ Remove ICellCommands interface - - ❌ Remove CellCommands.cs implementation - - ❌ Remove CLI CellCommands wrapper - - ❌ Remove ExcelCellTool (MCP) - - ❌ Remove all cell-* CLI commands - - ❌ Remove excel_cell MCP tool - - ✅ All functionality replaced by RangeCommands (single cell = range A1:A1) - -2. **SheetCommands** → **REFACTOR & SIMPLIFY** - - ❌ Remove ReadAsync (use RangeCommands.GetValuesAsync instead) - - ❌ Remove WriteAsync (use RangeCommands.SetValuesAsync instead) - - ❌ Remove ClearAsync (use RangeCommands.ClearContentsAsync instead) - - ❌ Remove AppendAsync (use RangeCommands.SetValuesAsync with calculated range) - - ✅ Keep sheet-level operations: List, Create, Rename, Delete, Copy (worksheet management) - - ✅ SheetCommands becomes purely worksheet lifecycle management - -3. **HyperlinkCommands** → **INTEGRATE INTO RANGECOMMANDS** - - ❌ Remove IHyperlinkCommands interface - - ❌ Remove HyperlinkCommands.cs implementation - - ❌ Remove CLI HyperlinkCommands wrapper - - ❌ Remove all hyperlink-* CLI commands - - ✅ Hyperlink operations become actions in RangeCommands - - ✅ Hyperlink operations integrated into range MCP tool - -4. **Result Types** → **SIMPLIFY** - - ❌ Remove CellValueResult (replaced by RangeValueResult) - - ❌ Remove WorksheetDataResult (replaced by RangeValueResult) - - ❌ Remove HyperlinkListResult (replaced by RangeHyperlinkResult) - - ❌ Remove HyperlinkInfoResult (replaced by RangeHyperlinkResult) - -### Clean Architecture Result - -**Before** (Fragmented): -``` -Commands/ -├── CellCommands.cs ← DELETE (4 methods) -├── SheetCommands.cs ← SLIM DOWN (9 methods → 5 methods) -├── HyperlinkCommands.cs ← DELETE (4 methods) -├── NamedRangeCommands.cs ← KEEP (named ranges) -├── TableCommands.cs ← KEEP (Excel tables) -└── ... -``` - -**After** (Unified): -``` -Commands/ -├── RangeCommands.cs ← NEW (30+ methods, all range operations) -├── SheetCommands.cs ← SIMPLIFIED (5 methods, worksheet lifecycle only) -├── NamedRangeCommands.cs ← KEEP (named ranges) -├── TableCommands.cs ← KEEP (Excel tables) -└── ... -``` - -### Implementation Strategy - MCP Server First, CLI Later - -**Phase 1A** - Core Foundation (MCP Server Focus): -1. ✅ Create RangeValueResult and RangeFormulaResult models -2. ✅ Create IRangeCommands interface (all 40 methods defined) -3. ⬜ Implement RangeCommands.cs (all 40 methods with Excel COM) -4. ⬜ Create RangeCommandsTests.cs with comprehensive integration tests -5. ⬜ **MCP Server**: Create ExcelRangeTool with all actions -6. ⬜ **MCP Server**: Update ExcelTools.cs to route to new range tool -7. ⬜ **MCP Server**: Update server.json with range tool definition -8. ⬜ **MCP Server**: Test all range operations via MCP protocol -9. ⬜ **CLI**: ONLY update commands that break due to refactoring (sheet-read, sheet-write if affected) -10. ⬜ **DELETE**: Remove CellCommands from Core (breaks CLI cell-* commands - acceptable) -11. ⬜ **DELETE**: Remove HyperlinkCommands from Core (breaks CLI hyperlink-* commands - acceptable) -12. ⬜ **DELETE**: Remove excel_cell from MCP server (replaced by range) - -**Phase 1B** - CLI Implementation (After MCP Server Complete): -1. ⬜ Create CLI RangeCommands wrapper (ExcelMcp.CLI/Commands/RangeCommands.cs) -2. ⬜ Add range-* commands to Program.cs routing -3. ⬜ Add CLI tests for range commands -4. ⬜ Remove old CLI commands (cell-*, hyperlink-*, sheet data operations) -5. ⬜ Update README.md and installation guides - -**Phase 2-4** - Future PRs (Number/Visual/Advanced Formatting): -- MCP Server first (add actions to range tool) -- CLI later (add range-* subcommands) - -### Breaking Changes Strategy - -**MCP Server** (Phase 1A): -- ✅ excel_cell tool → REMOVED (replaced by range) -- ✅ Cell operations in worksheet → REMOVED (use range) -- ✅ Hyperlink operations → MOVED to range - -**CLI** (Phase 1B): -- ⚠️ cell-* commands → REMOVED (replaced by range-* commands) -- ⚠️ hyperlink-* commands → REMOVED (replaced by range-* commands) -- ⚠️ sheet-read/write/clear/append → REFACTORED or REMOVED (use range-* commands) - -**Core** (Phase 1A): -- ❌ ICellCommands / CellCommands → DELETED -- ❌ IHyperlinkCommands / HyperlinkCommands → DELETED -- ⚠️ ISheetCommands / SheetCommands → REFACTORED (lifecycle only) - ---- - -## Implementation Order Details - -**Phase 1A** (MCP Server - This PR): -- ✅ Create RangeValueResult and RangeFormulaResult models -- ✅ Create IRangeCommands interface (core operations + hyperlinks + native Excel COM) -- ⬜ Implement RangeCommands.cs (values, formulas, clear, copy, insert/delete, find/replace, sort, hyperlinks, Excel COM operations) -- ⬜ Create RangeCommandsTests.cs with comprehensive tests -- ⬜ **DELETE CellCommands** (interface, implementation, MCP tool, tests - CLI breaks temporarily) -- ⬜ **DELETE HyperlinkCommands** (interface, implementation, tests - CLI breaks temporarily) -- ⬜ **REFACTOR SheetCommands** (remove Read/Write/Clear/Append from Core, keep lifecycle) -- ⬜ **MCP**: Create ExcelRangeTool for MCP server (replacing excel_cell tool) -- ⬜ **MCP**: Update ExcelTools.cs routing -- ⬜ **MCP**: Update server.json configuration -- ⬜ **MCP**: Integration tests for range tool -- ⬜ **CLI**: Minimal fixes for broken imports/references (don't add new range-* commands yet) -- ⬜ Update Core documentation and copilot instructions - -**Phase 1B** (CLI - Follow-up PR): -- ⬜ Create CLI RangeCommands wrapper -- ⬜ Add range-* commands to Program.cs -- ⬜ Remove old CLI command implementations (cell-*, hyperlink-*) -- ⬜ Update CLI tests -- ⬜ Update README.md, INSTALLATION.md -- ⬜ Update CLI-specific copilot instructions - -**Phase 2** (Future PR) - Number Formatting: -- Add number formatting operations to IRangeCommands -- Implement in RangeCommands.cs -- MCP: Add actions to range tool -- CLI: Add range-format-* commands -- Tests for number formats -- Update documentation - -**Phase 3** (Future PR) - Visual Formatting: -- Add visual formatting operations (fonts, colors, borders, alignment) -- Implement in RangeCommands.cs -- MCP: Add actions to range tool -- CLI: Add range-style-* commands -- Tests for visual formatting -- Update documentation - -**Phase 4** (Future PR) - Advanced Features: -- Add advanced features (validation, merge, auto-fit, comments, protection, grouping) -- Implement in RangeCommands.cs -- MCP: Add actions to range tool -- CLI: Add range-advanced-* commands -- Tests for advanced features -- Update documentation - ---- - -## Refactoring Existing Commands into Range API - -### Analysis from LLM Perspective - -**As an LLM using the MCP server**, I currently use different tools for similar operations: - -#### Current Fragmentation - -**Excel Worksheet Tool** (9 actions): -- `list` - List worksheets ✅ **KEEP** (metadata/lifecycle) -- `read` - Read range data → **MOVE TO** `range.get-values` -- `write` - Write CSV to range → **MOVE TO** `range.set-values` (CLI CSV conversion) -- `create` - Create worksheet ✅ **KEEP** (lifecycle) -- `rename` - Rename worksheet ✅ **KEEP** (lifecycle) -- `copy` - Copy worksheet ✅ **KEEP** (lifecycle) -- `delete` - Delete worksheet ✅ **KEEP** (lifecycle) -- `clear` - Clear range → **MOVE TO** `range.clear` -- `append` - Append to range → **MOVE TO** `range.append-values` - -**Excel Cell Tool** (4 actions): -- `get-value` - Get single cell value → **REPLACE WITH** `range.get-values` (1x1 range) -- `set-value` - Set single cell value → **REPLACE WITH** `range.set-values` (1x1 range) -- `get-formula` - Get single cell formula → **REPLACE WITH** `range.get-formulas` (1x1 range) -- `set-formula` - Set single cell formula → **REPLACE WITH** `range.set-formulas` (1x1 range) - -### Unified Design - LLM Perspective - -**After refactoring, as an LLM I will have**: - -**Excel Worksheet Tool** (5 actions) - Pure lifecycle management: -- `list` - List all worksheets -- `create` - Create new worksheet -- `rename` - Rename worksheet -- `copy` - Copy worksheet -- `delete` - Delete worksheet - -**Excel Range Tool** (38+ actions) - All data operations: -- `get-values` - Read any range (single cell = 1x1, whole sheet = UsedRange) -- `set-values` - Write any range (replaces write, set-value) -- `append-values` - Append rows to range (replaces append) -- `clear` - Clear range (replaces clear, supports variants) -- `get-formulas`, `set-formulas` - Formula operations (replaces get-formula, set-formula) -- Plus 30+ more specialized range operations - -### Impact Analysis - -**Actions Deleted** (13 total): -- From `worksheet`: `read`, `write`, `clear`, `append` (4 actions) -- From `excel_cell`: ALL 4 actions -- All hyperlink actions (if any): estimate 5 actions - -**Actions Added** (38 new): -- All IRangeCommands methods become MCP actions - -**Net Result**: More focused tools, clearer separation of concerns, unified interface. - -### Code Refactoring Plan - -#### Phase 1A: Core & MCP Server - -**Delete**: -- `ICellCommands.cs`, `CellCommands.cs` (Core) -- `IHyperlinkCommands.cs`, `HyperlinkCommands.cs` (Core) -- `ExcelCellTool.cs` (MCP Server) -- Result types: `CellValueResult`, `HyperlinkListResult`, etc. - -**Modify**: -- `ISheetCommands.cs` - Remove: `ReadAsync`, `WriteAsync`, `ClearAsync`, `AppendAsync` -- `ISheetCommands.cs` - Keep: `ListAsync`, `CreateAsync`, `RenameAsync`, `CopyAsync`, `DeleteAsync` -- `SheetCommands.cs` - Delete methods: `ReadAsync`, `WriteAsync`, `ClearAsync`, `AppendAsync`, `ParseCsv` -- `ExcelWorksheetTool.cs` - Remove actions: `read`, `write`, `clear`, `append` -- `ExcelWorksheetTool.cs` - Keep actions: `list`, `create`, `rename`, `copy`, `delete` - -**Add**: -- `IRangeCommands.cs` (38 methods) - NEW -- `RangeCommands.cs` (implementation) - NEW -- `ExcelRangeTool.cs` (38 actions) - NEW -- Result types: `RangeValueResult`, `RangeFormulaResult`, etc. - MOSTLY DONE - -#### Phase 1B: CLI - -**Delete**: -- `CLI/Commands/CellCommands.cs` -- CLI commands: `cell-get-value`, `cell-set-value`, `cell-get-formula`, `cell-set-formula` - -**Modify**: -- `CLI/Commands/SheetCommands.cs` - Remove: `Read`, `Write`, `Clear`, `Append` -- `CLI/Commands/SheetCommands.cs` - Keep: `List`, `Create`, `Rename`, `Copy`, `Delete` -- `CLI/Program.cs` - Remove routing for deleted commands - -**Add**: -- `CLI/Commands/RangeCommands.cs` (wraps Core with CSV conversion) -- CLI commands: `range-get-values`, `range-set-values`, `range-append-values`, `range-clear`, `range-get-formulas`, `range-set-formulas`, etc. - -### Migration Path for Users - -**Before** (fragmented): -```typescript -// Read data - uses worksheet tool -worksheet({ action: "read", excelPath: "data.xlsx", sheetName: "Sales", range: "A1:D100" }) - -// Get single cell - uses cell tool -excel_cell({ action: "get-value", excelPath: "data.xlsx", sheetName: "Sales", cell: "A1" }) - -// Clear range - uses worksheet tool -worksheet({ action: "clear", excelPath: "data.xlsx", sheetName: "Sales", range: "A1:D100" }) -``` - -**After** (unified): -```typescript -// Read data - uses range tool -range({ action: "get-values", excelPath: "data.xlsx", sheetName: "Sales", rangeAddress: "A1:D100" }) - -// Get single cell - uses range tool (1x1 range) -range({ action: "get-values", excelPath: "data.xlsx", sheetName: "Sales", rangeAddress: "A1" }) -// Returns: { values: [[value]] } - -// Clear range - uses range tool -range({ action: "clear", excelPath: "data.xlsx", sheetName: "Sales", rangeAddress: "A1:D100" }) - -// Bonus: Read entire sheet -range({ action: "get-used-range", excelPath: "data.xlsx", sheetName: "Sales" }) -``` - -**Key LLM Benefits**: -1. **Single tool for all data operations** - No more guessing which tool to use -2. **Consistent interface** - All actions use `rangeAddress` parameter -3. **More powerful** - Access to UsedRange, CurrentRegion, Find, Sort, etc. -4. **Named ranges work transparently** - No separate methods needed - ---- - -## Usage Examples - Single Cell vs Range - -### Single Cell Operations (1x1 Range) - -```csharp -// Get single cell value - returns 2D array with 1 row, 1 column -var result = await rangeCommands.GetValuesAsync(batch, "Sheet1", "A1"); -// result.Values = [[100]] - -// Set single cell value - accepts 2D array with 1 row, 1 column -await rangeCommands.SetValuesAsync(batch, "Sheet1", "A1", [[100]]); - -// Get single cell formula -var formulaResult = await rangeCommands.GetFormulasAsync(batch, "Sheet1", "C5"); -// formulaResult.Formulas = [["=SUM(A1:A10)"]] - -// Set single cell formula -await rangeCommands.SetFormulasAsync(batch, "Sheet1", "C5", [["=SUM(A1:A10)"]]); -``` - -### Multi-Cell Range Operations - -```csharp -// Get range values - returns 2D array -var result = await rangeCommands.GetValuesAsync(batch, "Sheet1", "A1:C3"); -// result.Values = [ -// [1, 2, 3], -// [4, 5, 6], -// [7, 8, 9] -// ] - -// Set range values -await rangeCommands.SetValuesAsync(batch, "Sheet1", "A1:C3", [ - [1, 2, 3], - [4, 5, 6], - [7, 8, 9] -]); -``` - -### MCP JSON Examples - -```json -// Single cell get-values -{ - "action": "get-values", - "sheetName": "Sheet1", - "rangeAddress": "A1" -} -// Returns: { "values": [[100]] } - -// Single cell set-values -{ - "action": "set-values", - "sheetName": "Sheet1", - "rangeAddress": "A1", - "values": [[100]] -} - -// Range get-values -{ - "action": "get-values", - "sheetName": "Sheet1", - "rangeAddress": "A1:C3" -} -// Returns: { "values": [[1,2,3],[4,5,6],[7,8,9]] } -``` - -### ⚠️ CRITICAL: Range Address Must Match Data Dimensions - -**ALWAYS specify the full range address matching your data dimensions.** - -```csharp -// ❌ WRONG: Single cell address with multi-cell data -await rangeCommands.SetValuesAsync(batch, "Sheet1", "A1", [ - ["Date", "Region", "Product", "Revenue"] // 1x4 array -]); -// May only write "Date" to A1, losing other columns! - -// ✅ CORRECT: Full range address -await rangeCommands.SetValuesAsync(batch, "Sheet1", "A1:D1", [ - ["Date", "Region", "Product", "Revenue"] -]); - -// ❌ WRONG: Two separate calls for headers + data -await rangeCommands.SetValuesAsync(batch, "Sheet1", "A1", [["Date", "Region"]]); -await rangeCommands.SetValuesAsync(batch, "Sheet1", "A2", [[1, "North"], [2, "South"]]); - -// ✅ CORRECT: Single call with full range -await rangeCommands.SetValuesAsync(batch, "Sheet1", "A1:B3", [ - ["Date", "Region"], // Headers - [1, "North"], // Data row 1 - [2, "South"] // Data row 2 -]); -``` - -**Why:** Excel COM does not reliably auto-expand from single cell addresses. Specifying the exact range ensures all data is written correctly. - -### CLI Examples - -```powershell -# Single cell - CLI may simplify to scalar for user convenience -excelcli range-get-values file.xlsx Sheet1 A1 -# Output: 100 (CLI unpacks [[100]] to scalar) - -# Range - CLI displays as table or JSON -excelcli range-get-values file.xlsx Sheet1 A1:C3 -# Output: Table or JSON 2D array - -# Single cell from CSV (CLI converts to [[value]]) -echo "100" > value.csv -excelcli range-set-values file.xlsx Sheet1 A1 value.csv - -# Range from CSV (CLI converts to 2D array) -excelcli range-set-values file.xlsx Sheet1 A1:C3 data.csv -``` - ---- - -## Result Types - -### Already Created - -```csharp -/// <summary> -/// Result for Excel range value operations -/// </summary> -public class RangeValueResult : ResultBase -{ - public string SheetName { get; set; } = string.Empty; - public string RangeAddress { get; set; } = string.Empty; - public List<List<object?>> Values { get; set; } = new(); - public int RowCount { get; set; } - public int ColumnCount { get; set; } -} - -/// <summary> -/// Result for Excel range formula operations -/// </summary> -public class RangeFormulaResult : ResultBase -{ - public string SheetName { get; set; } = string.Empty; - public string RangeAddress { get; set; } = string.Empty; - public List<List<string>> Formulas { get; set; } = new(); - public List<List<object?>> Values { get; set; } = new(); - public int RowCount { get; set; } - public int ColumnCount { get; set; } -} -``` - -### Need to Add (Phase 2+) - -```csharp -public class RangeFormatResult : ResultBase -{ - public string SheetName { get; set; } = string.Empty; - public string RangeAddress { get; set; } = string.Empty; - public List<List<string>> NumberFormats { get; set; } = new(); -} - -public class RangeHyperlinkResult : ResultBase -{ - public string SheetName { get; set; } = string.Empty; - public string RangeAddress { get; set; } = string.Empty; - public List<HyperlinkInfo> Hyperlinks { get; set; } = new(); -} -``` - ---- - -## CLI Commands - -> **⚠️ CSV Support is CLI-ONLY** -> Core and MCP Server use `List<List<object?>>` (2D arrays). -> CLI converts CSV ↔ 2D arrays for user convenience (testing, scripting). - -### Phase 1 Commands (Replacing cell-*, hyperlink-*, and sheet data commands) - -```powershell -# === VALUE OPERATIONS (replaces cell-get-value, cell-set-value, sheet-read, sheet-write) === -excelcli range-get-values <file.xlsx> <sheet> <range> # Output: JSON or table -excelcli range-set-values <file.xlsx> <sheet> <range> <data.csv> # CLI-ONLY: Reads CSV, converts to 2D array - -# === FORMULA OPERATIONS (replaces cell-get-formula, cell-set-formula) === -excelcli range-get-formulas <file.xlsx> <sheet> <range> # Output: JSON or table -excelcli range-set-formulas <file.xlsx> <sheet> <range> <formulas.csv> # CLI-ONLY: Reads CSV, converts to 2D array - -# === CLEAR OPERATIONS (replaces sheet-clear) === -excelcli range-clear-all <file.xlsx> <sheet> <range> -excelcli range-clear-contents <file.xlsx> <sheet> <range> -excelcli range-clear-formats <file.xlsx> <sheet> <range> - -# === COPY OPERATIONS === -excelcli range-copy <file.xlsx> <srcSheet> <srcRange> <tgtSheet> <tgtRange> -excelcli range-copy-values <file.xlsx> <srcSheet> <srcRange> <tgtSheet> <tgtRange> -excelcli range-copy-formulas <file.xlsx> <srcSheet> <srcRange> <tgtSheet> <tgtRange> - -# === HYPERLINK OPERATIONS (replaces hyperlink-add, hyperlink-remove, hyperlink-list, hyperlink-get) === -excelcli range-add-hyperlink <file.xlsx> <sheet> <cell> <url> [displayText] [tooltip] -excelcli range-remove-hyperlink <file.xlsx> <sheet> <range> -excelcli range-list-hyperlinks <file.xlsx> <sheet> -excelcli range-get-hyperlink <file.xlsx> <sheet> <cell> -``` - -### Removed Commands - -```powershell -# ❌ DELETED - Use range-* commands instead -cell-get-value -cell-set-value -cell-get-formula -cell-set-formula - -# ❌ DELETED - Use range-* commands instead -hyperlink-add -hyperlink-remove -hyperlink-list -hyperlink-get - -# ❌ DELETED - Use range-* commands instead -sheet-read # Use range-get-values -sheet-write # Use range-set-values -sheet-clear # Use range-clear-* -sheet-append # Use range-set-values with calculated range - -# ✅ KEPT - Worksheet lifecycle management -sheet-list -sheet-create -sheet-rename -sheet-copy -sheet-delete -``` - ---- - -## MCP Tool: range - -> **⚠️ MCP Uses JSON, NOT CSV** -> Parameters use JSON arrays (2D): `[[value1, value2], [value3, value4]]` -> No CSV support in MCP Server. - -### Phase 1 Actions (Replacing excel_cell tool) - -```typescript -{ - "name": "range", - "description": "Comprehensive Excel range operations - values, formulas, hyperlinks, formatting, and more", - "parameters": { - "action": "string", - "excelPath": "string", - "sheetName": "string", - "rangeAddress": "string", - "values": "array<array<any>>", // JSON 2D array, NOT CSV - "formulas": "array<array<string>>", // JSON 2D array, NOT CSV - // ... other parameters - }, - "actions": [ - // Value operations (replaces excel_cell get-value, set-value) - "get-values", // Returns: { values: [[val1, val2], [val3, val4]] } - "set-values", // Input: { values: [[val1, val2], [val3, val4]] } - - // Formula operations (replaces excel_cell get-formula, set-formula) - "get-formulas", // Returns: { formulas: [["=A1+B1", "=SUM(A:A)"]] } - "set-formulas", // Input: { formulas: [["=A1+B1", "=SUM(A:A)"]] } - - // Clear operations - "clear-all", - "clear-contents", - "clear-formats", - - // Copy operations - "copy", - "copy-values", - "copy-formulas", - - // Hyperlink operations (replaces excel_hyperlink tool) - "add-hyperlink", - "remove-hyperlink", - "list-hyperlinks", - "get-hyperlink" - ] -} -``` - -### Removed MCP Tools - -```typescript -// ❌ DELETED - Replaced by range -{ - "name": "excel_cell", // All actions moved to range - "actions": ["get-value", "set-value", "get-formula", "set-formula"] -} - -// ❌ DELETED - Replaced by range -{ - "name": "excel_hyperlink", // All actions moved to range - "actions": ["add", "remove", "list", "get"] -} -``` - -### Modified MCP Tool: worksheet - -```typescript -{ - "name": "worksheet", - "description": "Worksheet lifecycle management - create, rename, copy, delete sheets", - "actions": [ - "list", // ✅ KEPT - "create", // ✅ KEPT - "rename", // ✅ KEPT - "copy", // ✅ KEPT - "delete", // ✅ KEPT - // ❌ REMOVED: "read", "write", "clear", "append" (use range instead) - ] -} -``` - ---- - -## Testing Strategy - -### Unit Tests (Fast, No Excel) -- Input validation -- Range address parsing -- Error handling logic - -### Integration Tests (Requires Excel) -- Single-cell operations -- Multi-cell operations -- Large ranges (performance) -- Edge cases (merged cells, formulas referencing other sheets) -- Error conditions (invalid ranges, protected sheets) - -### Test Data Patterns -```csharp -// Small range (2x2) -var values = new List<List<object?>> { - new() { "A1", "B1" }, - new() { "A2", "B2" } -}; - -// Large range (1000x10) for performance testing -var largeData = GenerateTestData(rows: 1000, cols: 10); - -// Formulas with references -var formulas = new List<List<string>> { - new() { "=A1+B1", "=SUM(A1:B1)" }, - new() { "=A2*2", "=AVERAGE(A:A)" } -}; -``` - ---- - -## Performance Considerations - -1. **Bulk Operations**: Use Excel's native 2D array support - - Single COM call for range vs N calls for N cells - - 100x-1000x faster for large ranges - -2. **Batching**: All operations use IExcelBatch pattern - - Multiple range operations in single Excel session - - Automatic save batching - -3. **Memory**: Large ranges handled efficiently - - Excel COM handles memory - - Stream CSV data for huge ranges - -## Relationship to Excel Tables (ListObjects) - -### Question: Can RangeCommands Manipulate Tables? - -**Answer: YES, with important distinctions** - -Excel Tables (ListObjects) are **structured ranges with metadata**. They exist in two layers: - -1. **The underlying range** - Can be manipulated with RangeCommands -2. **The table structure** - Requires TableCommands for metadata operations - -### What RangeCommands CAN Do with Tables - -✅ **Read/Write Data** - Access the underlying range -```csharp -// Read all data from a table (including headers) -var data = await rangeCommands.GetValuesAsync(batch, "Sheet1", "A1:D100"); - -// Write to specific cells within a table -await rangeCommands.SetValuesAsync(batch, "Sheet1", "B5:C10", newValues); - -// Update formulas in calculated columns -await rangeCommands.SetFormulasAsync(batch, "Sheet1", "E2:E100", formulas); -``` - -✅ **Format Table Cells** - Style the underlying range -```csharp -// Format number columns in a table -await rangeCommands.SetNumberFormatAsync(batch, "Sheet1", "C2:C100", "$#,##0.00"); - -// Apply conditional formatting to table data -await rangeCommands.SetBackgroundColorAsync(batch, "Sheet1", "D2:D100", RGB(255, 200, 200)); -``` - -✅ **Clear Table Data** (but preserves structure) -```csharp -// Clear data rows (keeps headers and table structure) -await rangeCommands.ClearContentsAsync(batch, "Sheet1", "A2:D100"); -``` - -### What ONLY TableCommands Can Do - -❌ **Table Structure Operations** - Requires TableCommands -```csharp -// Create a table from a range -await tableCommands.CreateAsync(batch, "Sheet1", "SalesTable", "A1:D100", hasHeaders: true); - -// Resize table (add/remove columns or rows) -await tableCommands.ResizeAsync(batch, "SalesTable", "A1:F150"); - -// Change table style -await tableCommands.SetStyleAsync(batch, "SalesTable", "TableStyleMedium2"); - -// Toggle totals row -await tableCommands.ToggleTotalsAsync(batch, "SalesTable", showTotals: true); - -// Set column total functions (SUM, AVERAGE, etc.) -await tableCommands.SetColumnTotalAsync(batch, "SalesTable", "Amount", "SUM"); - -// Add table to Data Model -await tableCommands.AddToDataModelAsync(batch, "SalesTable"); -``` - -### Best Practices for Table Manipulation - -**Scenario 1: Updating Table Data** -```csharp -// ✅ GOOD - Use RangeCommands for data operations -await rangeCommands.SetValuesAsync(batch, "Sheet1", "B2:B50", updatedPrices); -``` - -**Scenario 2: Reformatting Table Columns** -```csharp -// ✅ GOOD - Use RangeCommands for formatting -await rangeCommands.SetNumberFormatAsync(batch, "Sheet1", "C2:C100", "0.00%"); -``` - -**Scenario 3: Adding Rows to Table** -```csharp -// ⚠️ OPTION A - Use TableCommands for automatic table expansion -await tableCommands.AppendRowsAsync(batch, "SalesTable", csvData); - -// ⚠️ OPTION B - Use RangeCommands if you know exact range -await rangeCommands.SetValuesAsync(batch, "Sheet1", "A101:D105", newRows); -// Then resize table to include new rows -await tableCommands.ResizeAsync(batch, "SalesTable", "A1:D105"); -``` - -**Scenario 4: Creating Calculated Columns** -```csharp -// ✅ BEST - Use RangeCommands for formulas -await rangeCommands.SetFormulasAsync(batch, "Sheet1", "E2:E100", - new List<List<string>> { - new() { "=[@Amount]*[@Quantity]" } // Table structured reference - }); -``` - -### Recommended Approach - -**Keep Both APIs** - They serve different purposes: - -1. **TableCommands** - Table lifecycle and structure - - Create, rename, delete tables - - Resize, change styles - - Totals row management - - Data Model integration - - Table-specific operations (AppendRows with auto-expansion) - -2. **RangeCommands** - Data and formatting - - Read/write values and formulas - - Format cells (numbers, fonts, colors, borders) - - Clear data - - Copy/paste operations - - Works on ANY range (tables or not) - -### Updated Architecture Decision - -``` -Commands/ -├── RangeCommands.cs ← Data & formatting (any range, including tables) -├── TableCommands.cs ← Table structure & lifecycle (ListObject metadata) -├── SheetCommands.cs ← Worksheet lifecycle -├── NamedRangeCommands.cs ← Named ranges -└── ... -``` - -**Rationale:** -- **RangeCommands** = Low-level, works everywhere -- **TableCommands** = High-level, table-specific features -- **Complementary**, not redundant - -### Example: Complete Table Workflow - -```csharp -// 1. Create table structure (TableCommands) -await tableCommands.CreateAsync(batch, "Sales", "SalesTable", "A1:D1", - hasHeaders: true, tableStyle: "TableStyleMedium2"); - -// 2. Populate with data (RangeCommands) -await rangeCommands.SetValuesAsync(batch, "Sales", "A2:D100", salesData); - -// 3. Format currency column (RangeCommands) -await rangeCommands.SetNumberFormatAsync(batch, "Sales", "D2:D100", "$#,##0.00"); - -// 4. Add calculated column (RangeCommands) -await rangeCommands.SetFormulasAsync(batch, "Sales", "E2:E100", profitFormulas); - -// 5. Add totals row (TableCommands) -await tableCommands.ToggleTotalsAsync(batch, "SalesTable", true); -await tableCommands.SetColumnTotalAsync(batch, "SalesTable", "Amount", "SUM"); - -// 6. Read results (RangeCommands) -var results = await rangeCommands.GetValuesAsync(batch, "Sales", "A1:E101"); -``` - -This demonstrates how both APIs work together seamlessly! - ---- - -## Success Criteria - MCP Server First Approach - -### Phase 1A - MCP Server Complete (THIS PR) - -**Core Implementation**: -- [ ] All 40 Phase 1 operations implemented in RangeCommands.cs -- [ ] All operations tested with comprehensive integration tests -- [ ] CellCommands completely deleted (interface, implementation, MCP, tests) -- [ ] HyperlinkCommands completely deleted (interface, implementation, tests) -- [ ] SheetCommands refactored (removed Read/Write/Clear/Append, kept lifecycle operations) -- [ ] All old result types removed (CellValueResult, WorksheetDataResult, HyperlinkListResult, HyperlinkInfoResult) - -**MCP Server**: -- [ ] ExcelRangeTool created with all 40 actions -- [ ] excel_cell tool deleted (replaced by range) -- [ ] ExcelTools.cs routing updated -- [ ] server.json configuration updated -- [ ] MCP integration tests passing (all range actions work via protocol) -- [ ] MCP prompts updated for range operations - -**CLI Minimal Changes**: -- [ ] Import errors fixed (references to deleted commands) -- [ ] Broken tests removed/disabled temporarily -- [ ] Build succeeds (CLI commands may be missing functionality temporarily) - -**Documentation**: -- [ ] Copilot instructions updated (.github/instructions/) -- [ ] Core architecture documentation updated -- [ ] Breaking changes documented - -### Phase 1B - CLI Complete (Follow-up PR) - -**CLI Implementation**: -- [ ] CLI RangeCommands wrapper created -- [ ] range-* commands added to Program.cs -- [ ] Old CLI commands deleted (cell-*, hyperlink-*, sheet data operations) -- [ ] CLI tests updated and passing - -**Documentation**: -- [ ] README.md updated (breaking changes, migration guide) -- [ ] INSTALLATION.md updated if needed -- [ ] CLI-specific copilot instructions updated - -**Performance**: -- [ ] Performance benchmarks show 10x+ improvement for bulk operations (GetValues vs multiple GetValue calls) - -### Overall Success - -- [ ] MCP Server provides complete range automation (Phase 1A complete) -- [ ] CLI provides complete range automation (Phase 1B complete) -- [ ] All tests passing with 90%+ coverage -- [ ] No regression in existing functionality (Power Query, VBA, Tables, Parameters) -- [ ] Breaking changes clearly documented with migration examples - ---- - -## Power User Assessment & Missing Functionality - -### ⭐ Critical Operations Added to Phase 1 - -Based on Excel power user workflows, the following operations were **ADDED to Phase 1** as essential: - -1. **Insert/Delete Operations** (6 methods) - - `InsertCellsAsync`, `DeleteCellsAsync` (shift cells) - - `InsertRowsAsync`, `DeleteRowsAsync` (entire rows) - - `InsertColumnsAsync`, `DeleteColumnsAsync` (entire columns) - - **Why Critical**: Data manipulation workflows (inserting rows, removing blanks, restructuring) - -2. **Find/Replace Operations** (2 methods) - - `FindAsync` (search with options: match case, whole cell, formulas vs values) - - `ReplaceAsync` (bulk replacement with regex-like patterns) - - **Why Critical**: Data cleaning, standardization, error correction - -3. **Sort Operations** (1 method) - - `SortAsync` (multi-column sort with ascending/descending) - - **Why Critical**: Data analysis, report preparation, ranked lists - -### 🔄 Operations Moved to Separate Command Class - -Some operations are **intentionally excluded** from RangeCommands because they belong in separate, specialized command classes: - -1. **AutoFilter** → Create `IFilterCommands` (separate from RangeCommands) - - Filtering is complex enough to warrant dedicated commands - - Operations: Apply filter, modify filter criteria, clear filter, get filter state - - **Why Separate**: AutoFilter has state management (active/inactive), multiple filter types (values, top 10, custom, date filters), requires reading back filter state - -2. **PivotTables** → Future `IPivotCommands` (not in current scope) - - Pivot tables are their own abstraction layer - - Operations: Create pivot, add fields, set aggregation, refresh - - **Why Separate**: Complex object model distinct from ranges - -3. **Charts** → Future `IChartCommands` (not in current scope) - - Charts reference ranges but are separate objects - - Operations: Create chart, set series, modify axes, apply style - - **Why Separate**: Charts are worksheet objects, not range operations - -### ❌ Operations Intentionally Excluded (Not Power User Workflows) - -These operations are **NOT included** because they're rarely automated or better handled through Excel UI: - -1. **Freeze Panes** → Worksheet-level operation (not range-specific) - - Belongs in `ISheetCommands.FreezePanesAsync(sheetName, cellAddress)` if needed - - Rarely automated, mostly interactive user preference - -2. **Print Areas/Page Setup** → Worksheet-level operation - - Complex configuration rarely automated via COM - - Better handled through Excel UI or templates - -3. **Sparklines** → Specialized visualization (low automation value) - - Rarely automated programmatically - - Excel UI provides better visual design tools - -4. **Conditional Formatting** → Deferred to Phase 4 or separate - - Complex rule engine (icon sets, color scales, data bars, formulas) - - May warrant separate `IConditionalFormattingCommands` in future - - **Decision Needed**: Include in Phase 4 or create dedicated commands? - -### ✅ Final Phase 1 Scope (Revised) - -**Core Data Operations**: -- ✅ Get/Set Values (2D arrays) -- ✅ Get/Set Formulas (2D arrays) -- ✅ Clear (all/contents/formats/comments) -- ✅ Copy (all/values/formulas/formats) -- ✅ Insert/Delete cells/rows/columns -- ✅ Find/Replace -- ✅ Sort -- ✅ Hyperlinks (add/remove/list/get) - -**Native Excel COM Operations** (AI Agent Essential): -- ✅ GetUsedRangeAsync - `Worksheet.UsedRange` -- ✅ GetCurrentRegionAsync - `Range.CurrentRegion` -- ✅ GetRangeInfoAsync - `Range.Address`, `Range.Rows.Count`, `Range.Columns.Count`, `Range.NumberFormat` -- ✅ GetNamedRangeValuesAsync / SetNamedRangeValuesAsync - `Workbook.Names("name").RefersToRange` - -**Phase 1 Result**: ~40 methods covering 95% of daily Excel power user data manipulation workflows + essential AI agent discovery operations. - -### 🤔 Open Questions for Architect - -1. **AutoFilter Complexity**: - - Create separate `IFilterCommands` now or defer to Phase 2? - - Recommendation: **Create now** - filtering is essential for data workflows - -2. **Conditional Formatting**: - - Include in Phase 4 RangeCommands or create `IConditionalFormattingCommands`? - - Recommendation: **Defer to separate commands** - too complex for Range API - -3. **Comments vs Notes**: - - Excel has "Comments" (threaded) and "Notes" (legacy) - - Which should we support? - - Recommendation: **Support both** - `AddCommentAsync` (threaded) and `AddNoteAsync` (legacy) - -4. **Protection Granularity**: - - Should protection be range-level (lock/unlock cells) or worksheet-level? - - Current spec: Range-level `LockCellsAsync` / `UnlockCellsAsync` - - Also need: Worksheet-level `ProtectSheetAsync` / `UnprotectSheetAsync` in SheetCommands - - Recommendation: **Both** - range sets locked property, worksheet enables protection - ---- - -## Updated Implementation Plan - MCP Server First - -### Phase 1A: Core Range + MCP Server (THIS PR) -- **Target**: 40 methods + complete MCP integration -- **Timeline**: 4-5 days -- **Focus**: MCP Server functionality complete, CLI minimal changes -- **Scope**: - - ✅ Core implementation (RangeCommands.cs with 40 methods) - - ✅ Integration tests (RangeCommandsTests.cs) - - ✅ MCP Server tool (ExcelRangeTool with all actions) - - ✅ Delete obsolete Core commands (CellCommands, HyperlinkCommands) - - ✅ Refactor SheetCommands (lifecycle only) - - ⚠️ CLI minimal fixes (import errors only, no new range-* commands) - - ✅ Core documentation updates - -### Phase 1B: CLI Implementation (Follow-up PR) -- **Target**: Complete CLI range-* commands -- **Timeline**: 2-3 days -- **Focus**: Full CLI support for range operations -- **Scope**: - - ✅ CLI RangeCommands wrapper - - ✅ Add range-* commands to Program.cs - - ✅ Remove old CLI commands (cell-*, hyperlink-*) - - ✅ CLI tests - - ✅ Update README.md, INSTALLATION.md - -### Phase 1.5: AutoFilter Commands (Separate PR) -- **Create**: `IFilterCommands` interface and implementation -- **Target**: 5-7 methods for AutoFilter workflows -- **Timeline**: 1-2 days -- **Scope**: - - ✅ MCP Server first (filter actions in worksheet or new tool) - - ✅ CLI later (filter-* commands) - -### Phase 2: Number Formatting (Future PR) -- **Timeline**: 1 day -- **MCP First**: Add actions to range -- **CLI Later**: Add range-format-* commands - -### Phase 3: Visual Formatting (Future PR) -- **Timeline**: 2 days -- **MCP First**: Add actions to range -- **CLI Later**: Add range-style-* commands - -### Phase 4: Advanced Features (Future PR) -- **Timeline**: 2 days -- **MCP First**: Add actions to range -- **CLI Later**: Add range-advanced-* commands - -**Total Phase 1 (MCP + CLI)**: 6-8 days -**Total All Phases**: ~12-15 days for comprehensive Range API (MCP priority) diff --git a/specs/SESSION-API-REDESIGN-SPEC.md b/specs/SESSION-API-REDESIGN-SPEC.md deleted file mode 100644 index 52bb3118..00000000 --- a/specs/SESSION-API-REDESIGN-SPEC.md +++ /dev/null @@ -1,1118 +0,0 @@ -# Session API Redesign Specification - -**Version:** 1.0 -**Status:** Draft -**Date:** 2025-01-13 -**Author:** Development Team - -## Executive Summary - -This specification proposes a **breaking redesign** of ExcelMcp's session API to use intuitive **Open/Save/Close** semantics exclusively. The goal is to eliminate the "batch" concept entirely and remove all cognitive load from LLMs - every operation works through sessions, always. No backwards compatibility, no dual patterns, no decisions about when to batch. - -## ⚠️ CRITICAL: Excel COM Threading & Concurrency Limitations - -**Excel COM API is fundamentally single-threaded and does NOT support parallel operations.** - -### Operations Within a Session are ALWAYS SERIAL - -- Each session (`IExcelBatch`/`IExcelSession`) runs on **ONE dedicated STA thread** with **ONE Excel instance** -- Operations are **queued** and executed **sequentially** via `Channel<Func<Task>>` -- Multiple `session.Execute()` calls are processed **one at a time** (never in parallel) -- This is a **COM interop requirement**, not an implementation choice - -**Example - Operations are SERIAL, not parallel:** - -```csharp -// ❌ These do NOT run in parallel - they are queued serially! -var task1 = session.Execute(ctx => ctx.Book.Worksheets.Add("Sheet1")); // Queued -var task2 = session.Execute(ctx => ctx.Book.Worksheets.Add("Sheet2")); // Queued AFTER task1 -await Task.WhenAll(task1, task2); // Still serial execution on STA thread! -``` - -**Why:** Excel COM requires Single-Threaded Apartment (STA) model. No concurrent access to Excel objects is possible. - -### Multiple Sessions = Multiple Excel Processes (Resource Heavy) - -**You CAN create multiple sessions for DIFFERENT files:** -- Each session = one `Excel.Application` process (~50-100MB+ memory each) -- Sessions run in **separate processes** (true parallelism between files) -- But **operations within each session remain serial** - -**Example - Multiple files (parallel processes, serial operations per file):** - -```csharp -// ✅ CORRECT: Multiple files = multiple Excel processes (true parallelism) -var session1 = await manager.CreateSessionAsync("fileA.xlsx"); // Excel process 1 -var session2 = await manager.CreateSessionAsync("fileB.xlsx"); // Excel process 2 - -// These run in parallel (different Excel processes) -var task1 = GetSession(session1).Execute(...); // Runs in Excel process 1 -var task2 = GetSession(session2).Execute(...); // Runs in Excel process 2 -await Task.WhenAll(task1, task2); // ✅ True parallelism (different processes) - -// But within each session, operations are still serial -var task3 = GetSession(session1).Execute(...); // Queued after task1 -var task4 = GetSession(session1).Execute(...); // Queued after task3 -``` - -**Resource Limits:** -- Each Excel process consumes ~50-100MB+ memory -- Windows desktop machines have finite resources -- **Recommendation:** Limit to 3-5 concurrent sessions for typical desktops - -### File Creation Must Be Sequential - -**File creation is automatically serialized by the implementation:** - -```csharp -// ✅ This is now SAFE - internal lock serializes calls automatically -var tasks = Enumerable.Range(1, 10).Select(i => - ExcelSession.CreateNew($"file{i}.xlsx", false, ...)); -await Task.WhenAll(tasks); // Executes sequentially despite Task.WhenAll! - -// ✅ This is also safe and more explicit -for (int i = 1; i <= 10; i++) -{ - await ExcelSession.CreateNew($"file{i}.xlsx", false, ...); -} -``` - -**How it works:** `ExcelSession` uses a static `SemaphoreSlim(1, 1)` to serialize all `CreateNew()` and `CreateNewAsync()` calls. Even if called in parallel, they queue and execute one at a time. - -**Why enforced:** Each `CreateNew()` temporarily creates an Excel instance, saves the file, then closes it. Without serialization, parallel creation would spawn many Excel processes simultaneously, causing memory exhaustion. - -### SessionManager Prevents Same-File Conflicts - -```csharp -// SessionManager enforces one session per file -await manager.CreateSessionAsync("sales.xlsx"); // ✅ OK -await manager.CreateSessionAsync("sales.xlsx"); // ❌ Throws: "File already open in another session" -``` - -This matches Excel UI behavior (cannot open same file twice). - -### Key Takeaways for Implementation - -1. **Within-session parallelism is IMPOSSIBLE** - all operations are queued serially on STA thread -2. **Between-sessions parallelism is POSSIBLE** - different files = different processes -3. **File creation is AUTOMATICALLY SERIALIZED** - enforced by SemaphoreSlim lock (prevents resource exhaustion) -4. **Resource limits matter** - each session = one Excel process (~50-100MB+) -5. **LLMs must manage session lifecycle carefully** - close sessions promptly to free resources - -## Problem Statement - -### Current Pain Points - -1. **Unintuitive Terminology**: "Begin batch" and "Commit batch" are technical terms that require explanation -2. **Cognitive Load**: LLMs must decide when to use batch mode vs. single operations -3. **Resource Leak Risk**: Forgotten commits leave Excel instances running -4. **Dual Patterns**: Tools support both `batchId` parameter and standalone operation modes (complexity!) -5. **Decision Fatigue**: LLMs waste tokens deciding "Should I use batch mode for this?" -6. **Performance Inconsistency**: Single operations are slow, batch is fast - LLM must choose correctly -7. **File Lock Race Condition (Issue #173)**: Rapid sequential non-batch calls fail because Excel disposal (2-17s) from first call hasn't completed when second call tries to open the file - -### What Users Actually Think - -Users and LLMs naturally think in terms of: - -- **Open** a file → work with it → **Save** changes → **Close** the file -- NOT: Begin session → track GUID → commit session - -This is the universal pattern for file operations across all systems. - -## Proposed Solution - -### High-Level Design - -**Sessions are the ONLY way to work with Excel files. No exceptions.** - -1. **`file(action: 'open')`** - Opens a workbook, returns a `sessionId` -2. **`file(action: 'save')`** - Saves changes to an open workbook -3. **`file(action: 'close')`** - Closes workbook and session -4. **ALL other tools REQUIRE `sessionId`** - No standalone operation mode - -**Revolutionary Change:** Remove the `batchId` optional parameter pattern entirely. Sessions are mandatory, not optional. - -### Why This Works Better - -| Old Pattern | New Pattern | Benefit | -|------------|-------------|---------| -| `excel_batch(action: 'begin')` | `file(action: 'open')` | Matches universal file paradigm | -| Track `batchId` GUID | Track `sessionId` (still a GUID) | More intuitive name | -| `excel_batch(action: 'commit', save: true)` | `file(action: 'close')` | Natural action name | -| Optional `batchId` parameter | **REQUIRED** `sessionId` parameter | No decision fatigue | -| "Should I use batch mode?" | Sessions always used | Zero cognitive load | -| Dual code paths (batch vs. single) | **Single code path only** | Simpler implementation | -| Rapid sequential calls fail (#173) | **Single Excel instance reused** | **Eliminates file lock race** | - -### Terminology Changes - -``` -Current Term → New Term → Rationale -──────────────────────────────────────────────────────── -batchId → sessionId → "Session" is more intuitive than "batch" -begin → open → Universal file operation -commit → close → Universal file operation (does NOT save) -batch-of-one → REMOVED → No standalone operations anymore -Optional → REQUIRED → sessionId is mandatory for all operations -save param → REMOVED → close never saves, use explicit save action -excelPath → REMOVED → Session knows the file (except open/create) -``` - -**BREAKING CHANGE:** The optional `batchId` parameter is completely removed. Every operation on a workbook requires an active session. - -## Detailed API Design - -### 1. `file` Tool - Updated Actions - -**Current Actions:** - -- `create-empty` - Create new workbook -- `close-workbook` - Emergency close (rarely used) -- `test` - Connection test - -**New Actions (added):** - -- **`open`** - Opens workbook, returns sessionId (replaces batch begin) -- **`save`** - Saves changes to open session -- **`close`** - Closes session WITHOUT saving (use explicit save action) - -**Removed Actions:** - -- None (keep create-empty, test, close-workbook for backwards compat) - -### 2. API Signatures - -#### Open Workbook - -```csharp -[McpServerTool(Name = "file")] -[Description(@"Manage Excel file lifecycle. All Excel operations require an active session. - -REQUIRED WORKFLOW: -1. open - Opens workbook, returns sessionId (ALWAYS FIRST) -2. Use sessionId for ALL operations (worksheets, queries, ranges, etc.) -3. save - Saves changes (EXPLICIT action, call anytime during session) -4. close - Closes workbook and session (NEVER saves - use explicit save action) - -Sessions are mandatory - there are no standalone operations.")] -public static async Task<string> ExcelFile( - [Required] - [Description("Action to perform")] - FileAction action, - - [Description("Full path to Excel file - required for 'open' and 'create-empty'")] - string? filePath = null, - - [Description("Session ID from 'open' action - required for 'save' and 'close'")] - string? sessionId = null) -{ - return action switch - { - FileAction.Open => await OpenWorkbookAsync(filePath!), - FileAction.Save => await SaveWorkbookAsync(sessionId!), - FileAction.Close => await CloseWorkbookAsync(sessionId!), // No save parameter - close NEVER saves - FileAction.CreateEmpty => await CreateEmptyAsync(filePath!), - FileAction.Test => TestConnection(), - _ => throw new McpException($"Unknown action: {action}") - }; -} -``` - -#### Example Response - Open - -```json -{ - "success": true, - "sessionId": "abc-123-def-456", - "filePath": "C:\\data\\sales.xlsx", - "message": "Workbook opened successfully", - "suggestedNextActions": [ - "Use sessionId='abc-123-def-456' for all operations", - "Call file(action: 'save', sessionId='...') to save changes (explicit only)", - "Call file(action: 'close', sessionId='...') when done (does NOT save)" - ], - "workflowHint": "Session active. Remember: close does NOT save - use explicit save action." -} -``` - -### 3. Other Tools - Breaking Changes - -**All other tools now REQUIRE `sessionId` parameter and REMOVE `excelPath` parameter**: - -```csharp -// PowerQuery example - sessionId is now REQUIRED, excelPath REMOVED -public static async Task<string> ExcelPowerQuery( - [Required] PowerQueryAction action, - [Required] string sessionId, // ✅ REQUIRED (was optional batchId) - // ... other params (excelPath REMOVED - session already knows the file) -) -``` - -**BREAKING CHANGE:** No more optional `batchId`. Every tool method signature changes to require `sessionId`. - -**BREAKING CHANGE:** `excelPath` parameter REMOVED from all tools except `file` open/create actions. The session already knows which file is open, so passing `excelPath` is redundant and creates potential for mismatches (sessionId points to fileA.xlsx, but excelPath says fileB.xlsx). - -**Implementation simplification:** - -- Remove `WithBatchAsync()` dual-path logic entirely -- Remove "batch-of-one" pattern -- Every tool method becomes simpler: just lookup session and use it -- No more "if sessionId provided, else create temporary batch" logic - -## Implementation Strategy - -### Single-Phase Breaking Refactor - -**No backwards compatibility. Clean slate redesign.** - -#### Step 1: Remove Old Infrastructure (1-2 days) - -1. **Delete `excel_batch` tool entirely** - - Remove `src/ExcelMcp.McpServer/Tools/BatchSessionTool.cs` - - Remove `src/ExcelMcp.CLI/Commands/BatchCommands.cs` - - Remove `src/ExcelMcp.McpServer/Prompts/Content/excel_batch.md` - -2. **Remove dual-path logic in ExcelToolsBase** - - Delete `WithBatchAsync()` method entirely - - Remove "batch-of-one" pattern - - Remove all `if (sessionId != null) { ... } else { ... }` conditionals - -3. **Rename internal classes** - - `_activeBatches` → `_activeSessions` - - `IExcelBatch` → `IExcelSession` (interface rename) - - `ExcelBatch` → `ExcelSession` (implementation rename) - - `BeginBatchAsync` → `OpenSessionAsync` - -#### Step 2: Add Session Lifecycle to file (2-3 days) - -```csharp -// Add new actions to existing file tool -public enum FileAction -{ - CreateEmpty, - Open, // NEW - replaces batch begin - Save, // NEW - explicit save - Close, // NEW - replaces batch commit - Test -} -``` - -**Implementation:** - -- `OpenWorkbookAsync()` - Creates session, returns sessionId -- `SaveWorkbookAsync(sessionId)` - Saves changes -- `CloseWorkbookAsync(sessionId, save)` - Closes and disposes - -#### Step 3: Update ALL Tools to Require sessionId (3-5 days) - -**Before (12 tools with optional batchId):** - -```csharp -string? batchId = null -``` - -**After (12 tools with required sessionId):** - -```csharp -[Required] string sessionId -``` - -**Files to modify:** - -- `ExcelConnectionTool.cs` -- `ExcelDataModelTool.cs` -- `ExcelFileTool.cs` (add open/save/close actions) -- `ExcelNamedRangeTool.cs` -- `ExcelPivotTableTool.cs` -- `ExcelPowerQueryTool.cs` -- `ExcelRangeTool.cs` -- `ExcelTableTool.cs` -- `ExcelVbaTool.cs` -- `ExcelWorksheetTool.cs` - -#### Step 4: Simplify Tool Implementation (2-3 days) - -**Remove complexity everywhere:** - -```csharp -// OLD - Complex dual-path logic -var result = await ExcelToolsBase.WithBatchAsync( - batchId, filePath, save: true, - async (batch) => await commands.SomeAsync(batch, args)); - -// NEW - Simple direct session lookup -var session = SessionManager.GetSession(sessionId); -var result = await commands.SomeAsync(session, args); -``` - -**Benefits:** - -- ~40% less code in each tool method -- No branching logic -- Easier to understand and maintain - -#### Step 5: Update Documentation (1-2 days) - -**Delete:** - -- `excel_batch.md` prompt file -- All references to "batch mode" in docs -- Performance comparison sections (sessions are ALWAYS used) - -**Update:** - -- `file.md` - Add session lifecycle patterns -- `tool_selection_guide.md` - Remove batch decision logic -- All tool descriptions - Change to "sessionId (required)" -- README - Update examples to show session workflow - -## Performance & Simplification - -### No More "Auto-Detection" - -**Current:** LLM must decide when to use batch mode (decision fatigue) -**New:** Sessions are mandatory - no decision needed - -**Key Insight:** By making sessions mandatory, we: - -1. **Eliminate decision fatigue** - LLM never thinks "Should I batch?" -2. **Consistent performance** - Every operation is optimized -3. **Simpler code** - Single code path through entire system -4. **Better UX** - Open/Close workflow is intuitive - -### Performance Best Practices - -#### ✅ DO: Batch Operations on Same File (Single Session) - -```csharp -// ✅ FAST: All operations in one session (single Excel instance reused) -var session = await CreateSessionAsync("sales.xlsx"); -await GetSession(session).Execute(ctx => ctx.Book.Worksheets.Add("Q1")); // Op 1 -await GetSession(session).Execute(ctx => ctx.Book.Worksheets.Add("Q2")); // Op 2 -await GetSession(session).Execute(ctx => ctx.Book.Worksheets.Add("Q3")); // Op 3 -await SaveSessionAsync(session); -await CloseSessionAsync(session); - -// Result: 1 Excel instance created/destroyed (fast) -``` - -#### ❌ DON'T: Open/Close Repeatedly (Multiple Excel Instances) - -```csharp -// ❌ SLOW: Creates 3 separate Excel instances (5-10x overhead per open/close) -await CreateSessionAsync("sales.xlsx"); -await GetSession(...).Execute(ctx => ctx.Book.Worksheets.Add("Q1")); -await CloseSessionAsync(...); - -await CreateSessionAsync("sales.xlsx"); // New Excel instance! -await GetSession(...).Execute(ctx => ctx.Book.Worksheets.Add("Q2")); -await CloseSessionAsync(...); - -await CreateSessionAsync("sales.xlsx"); // Yet another Excel instance! -await GetSession(...).Execute(ctx => ctx.Book.Worksheets.Add("Q3")); -await CloseSessionAsync(...); - -// Result: 3 Excel instances created/destroyed (very slow) -``` - -**Performance Impact:** Opening Excel repeatedly is 5-10x slower than reusing a session. The session API is designed to keep Excel open for multiple operations. - -#### ✅ DO: Parallel Processing of DIFFERENT Files - -```csharp -// ✅ TRUE PARALLELISM: Different files = different processes -var files = new[] { "sales.xlsx", "inventory.xlsx", "customers.xlsx" }; -var sessions = await Task.WhenAll(files.Select(f => CreateSessionAsync(f))); - -// Process all files in parallel (3 Excel processes running simultaneously) -await Task.WhenAll(sessions.Select(async sessId => { - var session = GetSession(sessId); - await session.Execute(ctx => ProcessWorkbook(ctx)); - await SaveSessionAsync(sessId); - await CloseSessionAsync(sessId); -})); - -// Result: True parallelism - operations on different files don't block each other -``` - -#### ❌ DON'T: Try to Parallelize Operations on SAME File - -```csharp -// ❌ NO BENEFIT: Operations are queued serially anyway (Excel COM limitation) -var sess = await CreateSessionAsync("data.xlsx"); -await Task.WhenAll( - GetSession(sess).Execute(ctx => ctx.Book.Worksheets.Add("Sheet1")), // Queued - GetSession(sess).Execute(ctx => ctx.Book.Worksheets.Add("Sheet2")), // Queued (waits) - GetSession(sess).Execute(ctx => ctx.Book.Worksheets.Add("Sheet3")) // Queued (waits) -); - -// Result: Operations still run serially (no speedup) - just write them sequentially for clarity -``` - -**Why No Benefit:** Each session has one STA thread processing operations one at a time. `Task.WhenAll` doesn't change this - they still execute serially on the Excel COM thread. - -#### File Creation (Automatically Serialized) - -```csharp -// ✅ This pattern works correctly - internal lock serializes calls -var tasks = Enumerable.Range(1, 10).Select(i => - ExcelSession.CreateNew($"report{i}.xlsx", false, - (ctx, ct) => { - ctx.Book.Worksheets[1].Name = $"Report {i}"; - return 0; - })); -await Task.WhenAll(tasks); // Executes sequentially despite Task.WhenAll! - -// ✅ This explicit sequential pattern also works -for (int i = 1; i <= 10; i++) -{ - await ExcelSession.CreateNew($"report{i}.xlsx", false, ...); -} - -// Result: Files created one at a time - peak memory = 1 temporary Excel instance -``` - -**How it works:** `ExcelSession` uses a static `SemaphoreSlim(1, 1)` to serialize all `CreateNew()` and `CreateNewAsync()` calls. Even if called via `Task.WhenAll`, they queue and execute one at a time. - -**Why enforced:** Each `CreateNew()` temporarily spawns an Excel process. Without serialization, parallel calls would create N Excel processes simultaneously, causing memory exhaustion. The lock prevents this automatically. - -### Code Simplification - -**OLD - Complex WithBatchAsync logic:** - -```csharp -public static async Task<T> WithBatchAsync<T>( - string? batchId, - string filePath, - bool save, - Func<IExcelBatch, Task<T>> action) -{ - if (!string.IsNullOrEmpty(batchId)) - { - // Path 1: Use existing batch - var batch = BatchSessionTool.GetBatch(batchId); - if (batch == null) throw new McpException(...); - if (!PathMatches(...)) throw new McpException(...); - return await action(batch); - } - else - { - // Path 2: Create temporary "batch-of-one" - await using var batch = await ExcelSession.BeginBatchAsync(filePath); - var result = await action(batch); - if (save) await batch.Save(); - return result; - } -} -``` - -**NEW - Simple session lookup:** - -```csharp -// Every tool method becomes: -var session = SessionManager.GetSession(sessionId); // Throws if not found -return await commands.SomeAsync(session, args); -``` - -**Lines of code saved:** ~200+ LOC across 12 tools - -### LLM Guidance - Sessions Are Always Required - -**Simplified prompt (no decisions):** - -```markdown -## Excel File Operations - ALWAYS Use Sessions - -**EVERY workflow follows this pattern:** -1. file(action: 'open', filePath: '...') → Get sessionId -2. Perform operations (ALL require sessionId) -3. file(action: 'close', sessionId: '...') → Close file - -**No exceptions.** You cannot list queries, create worksheets, or read ranges without an active session. - -**Single operation?** Still requires open/close: -```yaml -# Even for "just list worksheets" -1. file(action: 'open', filePath: 'data.xlsx') - → { sessionId: 'abc-123' } -2. worksheet(action: 'list', sessionId: 'abc-123') -3. file(action: 'close', sessionId: 'abc-123') -``` - -**Why?** Sessions ensure proper Excel COM lifecycle management. There are no "quick operations" - all operations are safe and optimized. - -Only `file(action: 'open'|'create-empty')` accepts `filePath`. All other tools use `sessionId` only and do not take a file path. - -``` - -**Cognitive load reduced to zero:** LLM no longer decides anything about performance optimization. - -## Migration Path - -### No Backwards Compatibility - -## Benefits Analysis - -### Fixes Issue #173: File Lock Race Condition - -**Problem:** In the current system, rapid sequential non-batch calls fail with file lock errors because: - -1. First call creates temporary Excel instance (batch-of-one) -2. First call completes, triggers `DisposeAsync()` (2-17 seconds) -3. Second call arrives before disposal completes -4. Second call tries to open same file → **FILE LOCKED ERROR** - -**How this spec eliminates the problem:** - -``` - -Current System (Issue #173): - Call 1: range(action, NO batchId) - → Create temp Excel → Use → Start disposal (2-17s background) - Call 2: range(action, NO batchId) - → Try create NEW Excel → File locked! ❌ - -New System (Mandatory Sessions): - Call 1: file(action: 'open') - → Create Excel instance, return sessionId - Call 2: range(action, sessionId='abc-123') - → Reuse SAME Excel instance ✅ - Call 3: range(action, sessionId='abc-123') - → Reuse SAME Excel instance ✅ - Call 4: file(action: 'close') - → Dispose Excel once (at end) - -``` - -**Key insight:** By requiring sessions, we eliminate the "create → dispose → create → dispose" cycle that causes the race condition. The Excel instance stays alive for the entire workflow. - -**Alternative considered:** Add retry logic with exponential backoff (proposed in #173) - but this is a **workaround** for a flawed architecture. Mandatory sessions **eliminate the root cause**. - -### For LLMs - -| Aspect | Before | After | Improvement | -|--------|--------|-------|-------------| -| **Decision Making** | "Should I use batch mode?" | No decision - sessions always used | ✅ Zero cognitive load | -| **Parameter Naming** | `batchId` (technical) | `sessionId` (familiar) | ✅ Intuitive | -| **Workflow Clarity** | Begin→Track GUID→Commit | Open→Work→Close | ✅ Universal pattern | -| **Learning Curve** | Must understand batching | Standard file operations | ✅ No explanation needed | -| **Error Recovery** | "Did I commit?" confusion | "Is file still open?" | ✅ Natural debugging | -| **Token Efficiency** | Decide + explain batch mode | Just open/close | ✅ 50% fewer tokens | -| **Code Complexity** | Handle optional parameter | sessionId always present | ✅ Simpler reasoning | - -### For Users - -| Aspect | Before | After | -|--------|--------|-------| -| **Terminology** | "What's a batch?" | "Opening a file" (universal) | -| **Documentation** | Explain batch optimization | No explanation needed | -| **Error Messages** | "Batch xyz not found" | "Session xyz not found" | -| **Tool Discovery** | Find excel_batch tool | Excel_file is obvious | - -### For Developers - -| Aspect | Impact | Benefit | -|--------|--------|---------| -| **Code Changes** | Breaking - remove dual paths, require sessionId | ✅ ~40% less code | -| **Infrastructure** | Rename classes (Batch→Session), single path | ✅ Half the complexity | -| **Testing** | Rewrite tests for session-only workflow | ✅ Simpler test setup | -| **Backwards Compat** | None - clean break | ✅ No legacy cruft | -| **Maintenance** | Single code path to maintain | ✅ Easier debugging | -| **New Features** | Build on simpler foundation | ✅ Faster development | - -## Implementation Checklist - -### Core Code Changes (Breaking) - -- [ ] **DELETE** `BatchSessionTool.cs` entirely -- [ ] **DELETE** `BatchCommands.cs` (CLI) entirely -- [ ] **DELETE** `excel_batch.md` prompt file -- [ ] **DELETE** `WithBatchAsync()` method in ExcelToolsBase -- [ ] **RENAME** `IExcelBatch` → `IExcelSession` (interface) -- [ ] **RENAME** `ExcelBatch` → `ExcelSession` (implementation) -- [ ] **RENAME** `_activeBatches` → `_activeSessions` in SessionManager -- [ ] **RENAME** `BeginBatchAsync` → `OpenSessionAsync` in ExcelSession -- [ ] **ADD** `FileAction.Open`, `FileAction.Save`, `FileAction.Close` enum values -- [ ] **IMPLEMENT** `OpenWorkbookAsync()`, `SaveWorkbookAsync()`, `CloseWorkbookAsync()` in ExcelFileTool -- [ ] **CHANGE** all 12 tools: `batchId` (optional) → `sessionId` (required) -- [ ] **REMOVE** `excelPath` parameter from all 11 tools (except file open/create) -- [ ] **REMOVE** `save` parameter from close action in file tool -- [ ] **SIMPLIFY** all tool methods: remove WithBatchAsync, direct session lookup -- [ ] **UPDATE** session to track filePath internally (for excelPath removal) - -### Testing (Complete Rewrite) - -- [ ] **DELETE** all batch-mode specific tests -- [ ] **REWRITE** all tool tests to use session pattern (open → operate → close) -- [ ] **ADD** session lifecycle tests (open, save, close actions) -- [ ] **ADD** error tests: operate without sessionId → clear error message -- [ ] **ADD** error tests: sessionId not found → helpful error -- [ ] **ADD** read-only workflow tests: open → read → close (no save) -- [ ] **ADD** multiple-save workflow tests: open → modify → save → modify → save → close -- [ ] **ADD** discard changes tests: open → modify → close (no save = rollback) -- [ ] **VERIFY** no performance regression (sessions were batches internally) -- [ ] **TEST** integration with MCP clients (Claude, Copilot) using new API -- [ ] **ADD** concurrency tests: verify operations within session are serial (not parallel) -- [ ] **ADD** multi-session tests: verify operations between sessions CAN run parallel -- [ ] **ADD** resource limit tests: verify 5+ concurrent sessions don't cause memory issues -- [ ] **ADD** file creation tests: verify sequential creation pattern (not parallel) - -### Documentation (Complete Rewrite) - -- [ ] **DELETE** `excel_batch.md` prompt file -- [ ] **DELETE** all references to "batch mode" and "when to batch" -- [ ] **REWRITE** `file.md` with session lifecycle patterns -- [ ] **REWRITE** `tool_selection_guide.md` (remove batch decision logic) -- [ ] **REWRITE** README examples (all use session pattern) -- [ ] **UPDATE** all 12 tool `[Description]` attributes: "sessionId (required)" -- [ ] **ADD** session lifecycle diagram to README -- [ ] **REWRITE** `examples/` directory scripts (all use open/close) -- [ ] **ADD** migration guide: "Breaking Changes in 2.0.0" - -### LLM Guidance - -- [ ] Create `session_lifecycle.md` prompt with open/save/close patterns -- [ ] Update `user_request_patterns.md` with session detection hints -- [ ] Add session error recovery guidance -- [ ] Update elicitations to ask about multi-operation intent -- [ ] **ADD** concurrency model documentation: operations within session are serial -- [ ] **ADD** performance guidance: batch operations on same file, parallelize different files -- [ ] **ADD** resource limits guidance: recommend 3-5 concurrent sessions max -- [ ] **ADD** file creation guidance: always sequential, never parallel - -## Edge Cases & Error Handling - -### Session Not Found - -**Before:** -```json -{ - "error": "Batch session 'xyz' not found. It may have already been committed..." -} -``` - -**After:** - -```json -{ - "success": false, - "errorMessage": "Session 'xyz' not found. The workbook may have already been closed.", - "isError": true, - "suggestedNextActions": [ - "Call file(action: 'open', filePath: '...') to open the workbook again", - "Check if another process closed the file" - ] -} -``` - -All tools MUST return JSON for business errors: - -- `success: false` -- `errorMessage`: human-readable reason -- `isError: true` -- `suggestedNextActions`: concrete next steps for the LLM - -MCP exceptions (`McpException`) are reserved for protocol issues only (missing/invalid parameters, unknown actions, missing files), not business logic failures. - -### Forgotten Close - -**Mitigation (client-side execution model):** - -1. **Process termination cleanup** - When MCP client (VS Code, Claude Desktop) closes, all Excel instances automatically close -2. **Manual process kill** - User can terminate Excel via Task Manager if needed -3. **Session listing** - Future enhancement: `file(action: 'list-sessions')` to show active sessions -4. **No automatic timeout** - Client-side execution means no server-side cleanup needed - -**Why this works:** - -- MCP server runs on user's machine (not remote server) -- Excel process lifetime tied to MCP client process lifetime -- User has full control via OS process management - -### File Locking - -No change - same Excel COM behavior. Sessions don't change locking semantics. - -### Multiple Workbooks / Sessions - -**You can open multiple files simultaneously, but understand the concurrency model:** - -1. Each file gets its own session (and Excel process) -2. Operations **between** files run in parallel (separate processes) -3. Operations **within** each file remain serial (Excel COM limitation - see CRITICAL section above) - -**Example:** - -```csharp -// Open 3 files (3 Excel processes created) -var sessA = await CreateSessionAsync("A.xlsx"); // Excel.Application process 1 -var sessB = await CreateSessionAsync("B.xlsx"); // Excel.Application process 2 -var sessC = await CreateSessionAsync("C.xlsx"); // Excel.Application process 3 - -// These 3 operations run in TRUE parallel (different processes) -await Task.WhenAll( - GetSession(sessA).Execute(ctx => ctx.Book.Worksheets.Add("Sheet1")), // Process 1 - GetSession(sessB).Execute(ctx => ctx.Book.Worksheets.Add("Sheet1")), // Process 2 - GetSession(sessC).Execute(ctx => ctx.Book.Worksheets.Add("Sheet1")) // Process 3 -); // ✅ True parallelism - different Excel processes - -// But operations on SAME file are SERIAL (queued) -await GetSession(sessA).Execute(ctx => ctx.Book.Worksheets.Add("Sheet2")); // Queued operation 1 -await GetSession(sessA).Execute(ctx => ctx.Book.Worksheets.Add("Sheet3")); // Queued operation 2 (waits for 1) -``` - -**Resource Limits & Best Practices:** - -- Each session = one Excel process (~50-100MB+ memory) -- **Recommendation:** Limit to 3-5 concurrent sessions for typical desktop machines -- LLMs should close sessions promptly to free resources -- Monitor system resources when processing many files - -**File Locking:** - -- SessionManager prevents opening same file twice: `File 'X.xlsx' is already open in another session` -- This matches Excel UI behavior (cannot open same file in multiple windows) -- Attempting to open an already-open file throws `InvalidOperationException` - -**LLM Guidance:** - -LLMs should track the correct `sessionId` for each workbook and close sessions when each logical workflow completes. For bulk file processing, consider sequential processing to limit resource usage: - -``` -# Processing many files - sequential approach (resource-friendly) -for each file: - 1. open → sessionId - 2. perform operations (all serial within session) - 3. save - 4. close (frees Excel process immediately) - -# OR parallel approach for small batches (faster but memory-intensive) -1. open files 1-5 → get 5 sessionIds (5 Excel processes) -2. process all 5 in parallel -3. close all 5 -4. repeat for files 6-10 -``` - -## Success Metrics - -### Quantitative - -- **Reduced token usage**: Session guidance ~40% shorter than batch guidance -- **Fewer errors**: Track "session not found" vs. "batch not found" rates -- **Adoption rate**: % of multi-operation workflows using sessions - -### Qualitative - -- **LLM feedback**: Do Claude/Copilot naturally use open/close without prompting? -- **User confusion**: Reduced questions about "what's a batch?" in docs/issues -- **Code clarity**: Naming matches intent in tool descriptions - - -## Design Decisions (Resolved) - -### 1. Should `open` action fail if workbook already open in Excel UI? - -**Decision:** Yes, fail immediately with clear error -**Rationale:** Excel COM limitation - we can't safely work with UI-open files -**Implementation:** Existing file lock detection works correctly - -### 2. Should `save` be implicit on `close` by default? - -**Decision:** No, close NEVER saves - explicit save action only -**Rationale:** - -- **Explicit is better than implicit** - No surprise saves -- **LLM clarity** - "save" action = save, "close" action = close (no overlap) -- **Read-only workflows** - Just open → read → close (no save needed) -- **Multiple saves** - Save multiple times during session, close at end -- **Predictable behavior** - close always does same thing (cleanup only) - -**Implementation:** Remove `save` parameter from close entirely. Users call `file(action: 'save')` explicitly when needed. - -### 3. Should sessions timeout automatically after inactivity? - -**Decision:** No automatic timeout - rely on process lifetime -**Rationale:** - -- **Client-side execution** - MCP server runs on user's machine, not remote server -- **Process lifetime** - When user closes MCP client (VS Code, Claude Desktop), process terminates and Excel closes -- **Manual control** - User can kill Excel process via Task Manager if needed -- **Simpler implementation** - No background timers, no timeout logic -- **No false positives** - No "session timed out" errors during long-running operations - -**Implementation:** No timeout logic. Sessions persist until explicitly closed or process terminates. - -### 4. What are the save semantics for different workflows? - -**Decision:** Explicit save action only, close never saves - -**Workflows supported:** - -1. **Read-only workflow:** - - ``` - open → read operations → close (no save needed) - ``` - - Use case: List queries, view data, check connections - -2. **Single save workflow:** - - ``` - open → modify operations → save → close - ``` - - Use case: Standard edit workflow - -3. **Multiple save workflow:** - - ``` - open → modify → save → modify → save → modify → save → close - ``` - - Use case: Incremental changes, checkpoints, complex multi-step operations - -4. **Discard changes workflow:** - - ``` - open → modify operations → close (no save = changes discarded) - ``` - - Use case: Experimental changes, testing, rollback - -**Rationale:** - -- **Explicit control** - User decides when to persist changes -- **Read-only support** - No save parameter needed anywhere -- **Flexibility** - Save 0, 1, or N times during session -- **Predictability** - close always does same thing (cleanup) - -### 5. Should we keep `excel_batch` as deprecated alias? - -**Decision:** No, complete removal -**Rationale:** - -- Maintaining alias adds complexity -- Breaking change anyway, might as well be clean -- Forces users to adopt new pattern completely -- No confusion from "two ways to do same thing" - -### 6. Should CLI and MCP Server both use same session API? - -**Decision:** Yes, unified API everywhere -**Rationale:** - -- CLI and MCP Server share Core/ComInterop -- Consistent experience across interfaces -- Same documentation applies to both -- No mode-specific quirks - -## Timeline - -**Estimated effort:** 2-3 weeks (one developer) - -**Week 1: Delete & Rename (Breaking Changes)** - -- Day 1-2: Delete batch infrastructure, rename classes -- Day 3-4: Add session lifecycle to file tool -- Day 5: Update 3-4 tools to require sessionId - -**Week 2: Tool Updates & Testing** - -- Day 1-3: Update remaining 8-9 tools to require sessionId -- Day 4: Simplify all tool implementations (remove WithBatchAsync) -- Day 5: Rewrite core tests for session-only pattern - -**Week 3: Integration & Documentation** - -- Day 1-2: Integration tests with MCP clients -- Day 3: Rewrite all documentation and examples -- Day 4: Migration guide, release notes, breaking change announcements -- Day 5: Beta release testing - -**Release Timeline:** - -- Week 4: Version 2.0.0-beta (breaking changes, early adopters) -- Week 6: Version 2.0.0 stable (after beta feedback) -- Month 6: Version 1.x end-of-life (final security patch) - -## Appendix: Example Workflows - -### Before (Batch API) - -``` -LLM: I'll create 3 worksheets using batch mode for performance. - -1. excel_batch(action: 'begin', filePath: 'sales.xlsx') - → { batchId: 'abc-123' } - -2. worksheet(action: 'create', excelPath: 'sales.xlsx', - sheetName: 'Q1', batchId: 'abc-123') - -3. worksheet(action: 'create', excelPath: 'sales.xlsx', - sheetName: 'Q2', batchId: 'abc-123') - -4. worksheet(action: 'create', excelPath: 'sales.xlsx', - sheetName: 'Q3', batchId: 'abc-123') - -5. excel_batch(action: 'commit', batchId: 'abc-123', save: true) - → { success: true } -``` - -### After (Session API) - -``` -LLM: I'll open the workbook and create 3 worksheets. - -1. file(action: 'open', filePath: 'sales.xlsx') - → { sessionId: 'abc-123' } - -2. worksheet(action: 'create', - sheetName: 'Q1', sessionId: 'abc-123') - -3. worksheet(action: 'create', - sheetName: 'Q2', sessionId: 'abc-123') - -4. worksheet(action: 'create', - sheetName: 'Q3', sessionId: 'abc-123') - -5. file(action: 'close', sessionId: 'abc-123') - → { success: true } -``` - -**Differences:** - -- "Begin batch" → "Open file" (natural language) -- "Commit batch" → "Close file" (universal action) -- `batchId` (optional) → `sessionId` (required) -- No more decision about "should I batch?" -- **Close never saves** - explicit save action only -- **excelPath removed** from all operations except open (session knows the file) -- Simpler: 5 calls vs 5 calls, but with intuitive naming - -### Read-Only Workflow Example - -``` -LLM: I'll check which Power Queries are in this workbook. - -1. file(action: 'open', filePath: 'sales.xlsx') - → { sessionId: 'abc-123' } - -2. powerquery(action: 'list', sessionId: 'abc-123') - → { queries: ['SalesData', 'CustomerInfo', 'ProductCatalog'] } - -3. file(action: 'close', sessionId: 'abc-123') - → { success: true } -``` - -**Key points:** - -- No save action needed (read-only operation) -- Close doesn't save (no changes made) -- Simple: open → read → close - -### Multiple-Save Workflow Example - -``` -LLM: I'll create multiple queries with checkpoints after each one. - -1. file(action: 'open', filePath: 'sales.xlsx') - → { sessionId: 'abc-123' } - -2. powerquery(action: 'import', sessionId: 'abc-123', - queryName: 'SalesData', mCodeFile: 'sales.m') - → { success: true } - -3. file(action: 'save', sessionId: 'abc-123') - → { success: true } // Checkpoint 1 - -4. powerquery(action: 'import', sessionId: 'abc-123', - queryName: 'CustomerInfo', mCodeFile: 'customers.m') - → { success: true } - -5. file(action: 'save', sessionId: 'abc-123') - → { success: true } // Checkpoint 2 - -6. powerquery(action: 'import', sessionId: 'abc-123', - queryName: 'ProductCatalog', mCodeFile: 'products.m') - → { success: true } - -7. file(action: 'save', sessionId: 'abc-123') - → { success: true } // Final checkpoint - -8. file(action: 'close', sessionId: 'abc-123') - → { success: true } -``` - -**Key points:** - -- Multiple explicit save actions during session -- Each save creates a checkpoint (changes persisted) -- Close at end does NOT save (last save already persisted everything) -- Incremental persistence reduces risk of data loss - -## Conclusion - -This redesign achieves the ultimate goal: **Eliminate all cognitive load from LLMs by making sessions the only way to work with Excel files**. The Open/Save/Close pattern is: - -1. **Universal** - Every developer/LLM knows file lifecycle (no explanation needed) -2. **Mandatory** - No decisions about batching, sessions are always used -3. **Simple** - Single code path, 40% less code to maintain -4. **Performant** - Same Excel COM optimization (sessions are batches internally) -5. **Breaking** - Clean slate, no backwards compatibility baggage -6. **Extensible** - Future optimizations (connection pooling, caching) build on simpler foundation -7. **Bug-fixing** - Eliminates Issue #173 file lock race condition by design -8. **Realistic** - Acknowledges and documents Excel COM threading limitations (single-threaded STA model) - -### Key Achievements - -**For LLMs:** - -- ✅ Zero decision fatigue (no "should I batch?" questions) -- ✅ 50% fewer tokens for workflows (no batch mode explanations) -- ✅ Intuitive API (open/close is universal) -- ✅ Clear concurrency model (operations within session = serial, between sessions = parallel) - -**For Developers:** - -- ✅ 40% less code (remove dual paths) -- ✅ Simpler testing (single pattern) -- ✅ Easier maintenance (one way to do things) -- ✅ Fixes Issue #173 (eliminates file lock race condition at architectural level) -- ✅ Honest documentation (acknowledges COM limitations, not hidden complexity) - -**For Users:** - -- ✅ Consistent performance (always optimized) -- ✅ Clear errors ("session not found" is obvious) -- ✅ Predictable behavior (explicit lifecycle) -- ✅ No more file lock errors from rapid sequential operations -- ✅ Realistic expectations (understand that operations within a file are serial due to Excel COM) - -### Critical Technical Constraints - -**This spec acknowledges and documents fundamental Excel COM limitations:** - -1. **Single-threaded nature** - Each session runs on one STA thread with serial operation queue -2. **No within-session parallelism** - Multiple operations on same file execute serially (COM requirement) -3. **Between-session parallelism possible** - Different files = different Excel processes = true parallelism -4. **Resource constraints** - Each session = ~50-100MB+ memory; recommend 3-5 concurrent sessions max -5. **File creation must be sequential** - Parallel creation causes resource exhaustion - -These are **not implementation deficiencies** - they are inherent Excel COM API constraints that apply to ANY Excel automation solution (VBA, .NET interop, Python xlwings, etc.). By documenting them clearly, we set correct expectations and guide users toward performant patterns. - -### Breaking Change Justification - -**Why break backwards compatibility?** - -1. **Current API is fundamentally flawed** - Optional batching creates decision fatigue -2. **Gradual migration would take years** - Dual paths would persist indefinitely -3. **Clean break is clearer** - Users update once vs. confused by deprecated patterns -4. **Version 2.0 is the right time** - Major version signals breaking changes -5. **Migration is straightforward** - Wrap operations in open/close (mechanical change) -6. **Fixes critical bug at architectural level** - Issue #173 file lock race condition eliminated by design (retry logic is just a workaround) - -**Recommendation:** ✅ **Approve for implementation in Version 2.0.0 (breaking release)** - -This is not just a rename - it's a fundamental simplification that makes ExcelMcp significantly easier for LLMs to use correctly while eliminating an entire class of file locking bugs. diff --git a/specs/SHEET-ENHANCEMENTS-SPEC.md b/specs/SHEET-ENHANCEMENTS-SPEC.md deleted file mode 100644 index 71466868..00000000 --- a/specs/SHEET-ENHANCEMENTS-SPEC.md +++ /dev/null @@ -1,732 +0,0 @@ -# Sheet Enhancement Specification - Tab Color & Visibility - -> **Enhanced worksheet commands for tab color and visibility management** -> -> **🤖 Primary Audience:** LLMs using MCP Server tools to automate Excel workbook organization - -## What This Spec Provides (For LLMs) - -This specification defines 8 new MCP Server actions that let you (an LLM) programmatically: - -### **Tab Colors** - Visual Organization -- **Set colors** using RGB values (0-255 each) - no need to know about BGR conversion -- **Read colors** to audit existing workbooks or preserve color schemes -- **Clear colors** to reset tabs to default appearance -- **Common use:** Color-code by department, project status, data category, priority level - -### **Sheet Visibility** - Control What Users See -- **Show/Hide** sheets with three levels of visibility -- **Hidden** - Users can unhide via Excel UI (for archive/reference data) -- **VeryHidden** - Only code can unhide (for templates, calculations, sensitive data) -- **Common use:** Protect formulas, hide templates, secure salary data, manage multi-user workbooks - -### **Why You Need These Tools** -When users ask you to "organize the sales workbook" or "set up a dashboard with calculations," you'll use these commands to: -1. Create professional-looking workbooks with color-coded tabs -2. Hide internal worksheets users shouldn't modify -3. Protect sensitive data from casual viewing -4. Implement visual workflows (red=todo, yellow=in-progress, green=complete) - ---- - -## Technical Overview - -This specification extends the existing SheetCommands to support two key worksheet appearance features: - -1. **Tab Color Management** - Set and retrieve worksheet tab colors (RGB → BGR conversion handled automatically) -2. **Visibility Control** - Show/hide worksheets with two protection levels (Hidden and VeryHidden) - ---- - -## Research: Excel Worksheet Capabilities - -### Tab Color (Worksheet.Tab.Color) - -**Color Format:** -- Excel uses **BGR (Blue-Green-Red) format** stored as integer -- RGB(255, 0, 0) becomes 0x0000FF (red in BGR) -- RGB(0, 255, 0) becomes 0x00FF00 (green) -- RGB(0, 0, 255) becomes 0xFF0000 (blue) -- Formula: `BGR = (Blue << 16) | (Green << 8) | Red` - -**ColorIndex Alternative:** -- Excel also supports `Tab.ColorIndex` property using `XlColorIndex` enum (legacy, limited palette) -- Modern approach: Use `Tab.Color` with RGB values - -**Official Reference:** -- [Tab.Color Property](https://learn.microsoft.com/en-us/dotnet/api/microsoft.office.interop.excel.tab.color) -- [Tab.ColorIndex Property](https://learn.microsoft.com/en-us/dotnet/api/microsoft.office.interop.excel.tab.colorindex) - -### Worksheet Visibility (Worksheet.Visible) - -**Excel COM API:** -- Access via `worksheet.Visible` property -- Set to `XlSheetVisibility` enum values -- Get returns current visibility state - -**Visibility Levels:** -1. **xlSheetVisible (-1)** - Normal visible state -2. **xlSheetHidden (0)** - Hidden via UI (right-click → Hide), user can unhide via UI -3. **xlSheetVeryHidden (2)** - Programmatically hidden, requires code to unhide (security/protection) - -**Official Reference:** -- [Worksheet.Visible Property](https://learn.microsoft.com/en-us/dotnet/api/microsoft.office.interop.excel._worksheet.visible) -- [XlSheetVisibility Enumeration](https://learn.microsoft.com/en-us/dotnet/api/microsoft.office.interop.excel.xlsheetvisibility) - ---- - -## Proposed API Design - -### Core Commands (ISheetCommands) - -```csharp -public interface ISheetCommands -{ - // === EXISTING LIFECYCLE COMMANDS (No changes) === - Task<WorksheetListResult> ListAsync(IExcelBatch batch); - Task<OperationResult> CreateAsync(IExcelBatch batch, string sheetName); - Task<OperationResult> RenameAsync(IExcelBatch batch, string oldName, string newName); - Task<OperationResult> CopyAsync(IExcelBatch batch, string sourceSheet, string newSheet); - Task<OperationResult> DeleteAsync(IExcelBatch batch, string sheetName); - - // === NEW: TAB COLOR OPERATIONS === - - /// <summary> - /// Sets the tab color for a worksheet using RGB values - /// Excel uses BGR format internally (Blue-Green-Red) - /// </summary> - void SetTabColor(IExcelBatch batch, string sheetName, int red, int green, int blue); - - /// <summary> - /// Gets the tab color for a worksheet - /// Returns RGB values or null if no color is set - /// </summary> - (int R, int G, int B)? GetTabColor(IExcelBatch batch, string sheetName); - - /// <summary> - /// Clears the tab color for a worksheet (resets to default) - /// </summary> - void ClearTabColor(IExcelBatch batch, string sheetName); - - // === NEW: VISIBILITY OPERATIONS === - - /// <summary> - /// Sets worksheet visibility level - /// </summary> - void SetVisibility(IExcelBatch batch, string sheetName, SheetVisibility visibility); - - /// <summary> - /// Gets worksheet visibility level - /// </summary> - SheetVisibility GetVisibility(IExcelBatch batch, string sheetName); - - /// <summary> - /// Shows a hidden or very hidden worksheet - /// Convenience method equivalent to SetVisibility(..., SheetVisibility.Visible) - /// </summary> - void Show(IExcelBatch batch, string sheetName); - - /// <summary> - /// Hides a worksheet (user can unhide via UI) - /// Convenience method equivalent to SetVisibility(..., SheetVisibility.Hidden) - /// </summary> - void Hide(IExcelBatch batch, string sheetName); - - /// <summary> - /// Very hides a worksheet (requires code to unhide) - /// Convenience method equivalent to SetVisibility(..., SheetVisibility.VeryHidden) - /// </summary> - void VeryHide(IExcelBatch batch, string sheetName); -} - -// === SUPPORTING TYPES === - -public enum SheetVisibility -{ - Visible = -1, // xlSheetVisible - Hidden = 0, // xlSheetHidden (can unhide via UI) - VeryHidden = 2 // xlSheetVeryHidden (requires code to unhide) -} - -public class TabColorResult : OperationResult -{ - public bool HasColor { get; set; } // False if no color is set - public int? Red { get; set; } // 0-255, null if no color - public int? Green { get; set; } // 0-255, null if no color - public int? Blue { get; set; } // 0-255, null if no color - public string? HexColor { get; set; } // #RRGGBB format for convenience -} - -public class SheetVisibilityResult : OperationResult -{ - public SheetVisibility Visibility { get; set; } - public string VisibilityName { get; set; } = string.Empty; // "Visible", "Hidden", "VeryHidden" -} -``` - -### Implementation Strategy - -**Tab Color Operations:** -- Convert RGB (0-255 each) to BGR format: `(blue << 16) | (green << 8) | red` -- Set via `worksheet.Tab.Color` property -- Get returns integer, convert back to RGB components -- Clear by setting to 0 or `XlColorIndex.xlColorIndexNone` -- Validate RGB values are 0-255 - -**Visibility Operations:** -- Map `SheetVisibility` enum to Excel's `XlSheetVisibility` constants -- Set via `worksheet.Visible` property -- Get returns integer, cast to `SheetVisibility` enum -- Convenience methods call `SetVisibility` with appropriate enum value - ---- - -## MCP Server Integration (Primary Use Case) - -### Updated worksheet Tool - -```typescript -{ - "name": "worksheet", - "description": "Worksheet lifecycle and appearance management", - "parameters": { - "action": "string", - "excelPath": "string", - "sheetName": "string", - "newSheetName": "string", // for rename/copy - "red": "number", // 0-255 for set-tab-color - "green": "number", // 0-255 for set-tab-color - "blue": "number", // 0-255 for set-tab-color - "visibility": "string" // "visible" | "hidden" | "veryhidden" - }, - "actions": [ - // Existing lifecycle operations - "list", - "create", - "rename", - "copy", - "delete", - - // New tab color operations - "set-tab-color", // Set tab color with RGB values - "get-tab-color", // Get current tab color - "clear-tab-color", // Clear tab color (reset to default) - - // New visibility operations - "set-visibility", // Set visibility level (visible/hidden/veryhidden) - "get-visibility", // Get current visibility level - "show", // Convenience: make visible - "hide", // Convenience: hide (user can unhide) - "very-hide" // Convenience: very hide (requires code) - ] -} -``` - -### MCP Action Examples - -**Set Tab Color:** -```json -{ - "action": "set-tab-color", - "excelPath": "Report.xlsx", - "sheetName": "Sales", - "red": 255, - "green": 0, - "blue": 0 -} -// Response: { "success": true } -``` - -**Get Tab Color:** -```json -{ - "action": "get-tab-color", - "excelPath": "Report.xlsx", - "sheetName": "Sales" -} -// Response: { "success": true, "hasColor": true, "red": 255, "green": 0, "blue": 0, "hexColor": "#FF0000" } -``` - -**Set Visibility:** -```json -{ - "action": "set-visibility", - "excelPath": "Report.xlsx", - "sheetName": "Data", - "visibility": "hidden" -} -// Response: { "success": true } -``` - -**Get Visibility:** -```json -{ - "action": "get-visibility", - "excelPath": "Report.xlsx", - "sheetName": "Data" -} -// Response: { "success": true, "visibility": "Hidden", "visibilityName": "Hidden" } -``` - ---- - -## MCP Action Reference (Quick Lookup for LLMs) - -**Use this table when deciding which action to call:** - -| Action | Required Parameters | Returns | When To Use | -|--------|-------------------|---------|-------------| -| `set-tab-color` | `sheetName`, `red`, `green`, `blue` | `{success}` | User wants to color-code sheets | -| `get-tab-color` | `sheetName` | `{success, hasColor, red, green, blue, hexColor}` | Need to read existing color or audit workbook | -| `clear-tab-color` | `sheetName` | `{success}` | User wants to remove color/reset to default | -| `set-visibility` | `sheetName`, `visibility` | `{success}` | Need precise control over visibility level | -| `get-visibility` | `sheetName` | `{success, visibility, visibilityName}` | Check current visibility state | -| `show` | `sheetName` | `{success}` | Make hidden/very-hidden sheet visible | -| `hide` | `sheetName` | `{success}` | Hide sheet (user can still unhide via UI) | -| `very-hide` | `sheetName` | `{success}` | Protect sheet from users (only code can unhide) | - ---- - -## Visibility Decision Guide (For LLMs) - -**When user says... → You should use:** - -| User Request | Action to Call | Visibility Level | Reason | -|--------------|---------------|------------------|---------| -| "hide the calculations" | `very-hide` | VeryHidden | User shouldn't see internal formulas | -| "hide the template" | `very-hide` | VeryHidden | Templates should be protected from editing | -| "protect lookup tables" | `very-hide` | VeryHidden | Reference data shouldn't be modified | -| "hide salary/sensitive data" | `very-hide` | VeryHidden | Security - prevent casual viewing | -| "hide archive data" | `hide` | Hidden | User may need to reference old data later | -| "hide temporary sheets" | `hide` | Hidden | May need manual cleanup/review | -| "hide for now" | `hide` | Hidden | Temporary hiding, user can unhide | -| "show all sheets" | `show` | Visible | Make previously hidden sheets visible | -| "unhide everything" | `show` (loop all) | Visible | Reveal all hidden worksheets | - -**Key Distinction:** -- **`hide`** (Hidden) - Users can right-click tabs → Unhide in Excel UI -- **`very-hide`** (VeryHidden) - Only code/automation can unhide (true protection) - -**Visibility Level Characteristics:** - -| Level | Excel Value | User Can See? | User Can Unhide? | When To Use | -|-------|-------------|---------------|------------------|-------------| -| **Visible** | `-1` | ✅ Yes | N/A | Normal sheets | -| **Hidden** | `0` | ❌ No | ✅ Yes (via UI) | Temporary hiding, archives | -| **VeryHidden** | `2` | ❌ No | ❌ No (code only) | Templates, formulas, sensitive data | - ---- - -## Use Cases (LLM Workflows) - -### 1. Color-Coding by Category (Single Operations) -**Scenario:** LLM organizing financial workbook by department - -```json -// Color code departments -{ "action": "set-tab-color", "sheetName": "Sales", "red": 0, "green": 176, "blue": 240 } // Blue -{ "action": "set-tab-color", "sheetName": "Marketing", "red": 255, "green": 192, "blue": 0 } // Orange -{ "action": "set-tab-color", "sheetName": "Operations", "red": 146, "green": 208, "blue": 80 } // Green -{ "action": "set-tab-color", "sheetName": "Summary", "red": 192, "green": 0, "blue": 0 } // Red -``` - -### 1b. Color-Coding by Category (Batch Mode - Recommended) -**Scenario:** LLM organizing financial workbook - efficient batch approach - -```json -// Step 1: Begin batch session -{ "tool": "begin_excel_batch", "excelPath": "Financial-Report.xlsx", "batchId": "color-coding" } - -// Step 2: Apply all colors in one session (no file saves between operations) -{ "tool": "worksheet", "action": "set-tab-color", "batchId": "color-coding", "sheetName": "Sales", "red": 0, "green": 176, "blue": 240 } -{ "tool": "worksheet", "action": "set-tab-color", "batchId": "color-coding", "sheetName": "Marketing", "red": 255, "green": 192, "blue": 0 } -{ "tool": "worksheet", "action": "set-tab-color", "batchId": "color-coding", "sheetName": "Operations", "red": 146, "green": 208, "blue": 80 } -{ "tool": "worksheet", "action": "set-tab-color", "batchId": "color-coding", "sheetName": "HR", "red": 112, "green": 48, "blue": 160 } -{ "tool": "worksheet", "action": "set-tab-color", "batchId": "color-coding", "sheetName": "Finance", "red": 255, "green": 217, "blue": 102 } -{ "tool": "worksheet", "action": "set-tab-color", "batchId": "color-coding", "sheetName": "Summary", "red": 192, "green": 0, "blue": 0 } - -// Step 3: Commit batch (saves once) -{ "tool": "commit_excel_batch", "batchId": "color-coding", "saveChanges": true } - -// Result: 6 sheets colored in ~2 seconds vs ~12 seconds with individual operations -``` - -### 2. Template Management -**Scenario:** LLM setting up workbook with hidden calculation sheets - -```json -// Hide template/calculation sheets from end users -{ "action": "very-hide", "sheetName": "Template" } -{ "action": "very-hide", "sheetName": "Calculations" } -{ "action": "very-hide", "sheetName": "LookupTables" } - -// Show only user-facing sheets -{ "action": "show", "sheetName": "Dashboard" } -{ "action": "show", "sheetName": "Summary" } -``` - -### 3. Workflow Status Indication -**Scenario:** LLM tracking project status with colors - -```json -// Use colors to indicate workflow status -{ "action": "set-tab-color", "sheetName": "ToDo", "red": 255, "green": 0, "blue": 0 } // Red - not started -{ "action": "set-tab-color", "sheetName": "InProgress", "red": 255, "green": 165, "blue": 0 } // Orange - in progress -{ "action": "set-tab-color", "sheetName": "Complete", "red": 0, "green": 255, "blue": 0 } // Green - done -``` - -### 4. Data Security -**Scenario:** LLM protecting sensitive calculations - -```json -// Very hide sheets containing sensitive calculations -{ "action": "very-hide", "sheetName": "SalaryData" } -{ "action": "very-hide", "sheetName": "Formulas" } - -// Can only unhide via code -{ "action": "show", "sheetName": "SalaryData" } // When authorized user needs access -``` - -### 5. Complete Workbook Setup Workflow -**Scenario:** LLM creating and organizing new workbook from scratch - -```json -// Step 1: Create sheets -{ "tool": "worksheet", "action": "create", "excelPath": "Q1-Report.xlsx", "sheetName": "Dashboard" } -{ "tool": "worksheet", "action": "create", "excelPath": "Q1-Report.xlsx", "sheetName": "Sales Data" } -{ "tool": "worksheet", "action": "create", "excelPath": "Q1-Report.xlsx", "sheetName": "Calculations" } -{ "tool": "worksheet", "action": "create", "excelPath": "Q1-Report.xlsx", "sheetName": "Lookup Tables" } - -// Step 2: Color-code by purpose -{ "tool": "worksheet", "action": "set-tab-color", "sheetName": "Dashboard", "red": 68, "green": 114, "blue": 196 } // Blue - user-facing -{ "tool": "worksheet", "action": "set-tab-color", "sheetName": "Sales Data", "red": 112, "green": 173, "blue": 71 } // Green - data -{ "tool": "worksheet", "action": "set-tab-color", "sheetName": "Calculations", "red": 255, "green": 192, "blue": 0 } // Orange - internal -{ "tool": "worksheet", "action": "set-tab-color", "sheetName": "Lookup Tables", "red": 158, "green": 158, "blue": 158 } // Gray - reference - -// Step 3: Hide internal sheets -{ "tool": "worksheet", "action": "very-hide", "sheetName": "Calculations" } -{ "tool": "worksheet", "action": "very-hide", "sheetName": "Lookup Tables" } - -// Step 4: Populate data (using other tools) -// ... range operations, Power Query, etc. - -// Result: Organized workbook with color-coded tabs and protected internal sheets -``` - ---- - -## Error Handling - -**Common Error Scenarios:** - -| Error Case | API Response | LLM Should... | -|------------|--------------|---------------| -| Sheet doesn't exist | `{success: false, errorMessage: "Sheet 'XYZ' not found"}` | Verify sheet exists with `list` action first | -| RGB out of range | `{success: false, errorMessage: "RGB values must be 0-255"}` | Validate RGB values before calling | -| Last visible sheet | `{success: false, errorMessage: "Cannot hide last visible sheet"}` | Check visibility of other sheets first | -| Invalid visibility value | `{success: false, errorMessage: "Invalid visibility: 'xyz'"}` | Use only: `visible`, `hidden`, `veryhidden` | - -**Best Practice for LLMs:** -```json -// Always check if operation succeeded -const result = worksheet({action: "set-tab-color", sheetName: "Sales", red: 255, green: 0, blue: 0}); -if (!result.success) { - // Handle error - maybe sheet was renamed or deleted - console.error(result.errorMessage); -} -``` - ---- - -## LLM Decision Logic (Your Automation Rules) - -**Apply these rules when processing user requests:** - -### 1. Color Keyword → RGB Mapping -When user says a color name, use these RGB values: - -| Color Name | RGB Values | Hex | Use For | -|------------|-----------|-----|---------| -| Red | `(255, 0, 0)` | `#FF0000` | Urgent, errors, high priority | -| Green | `(0, 255, 0)` | `#00FF00` | Complete, approved, success | -| Blue | `(0, 0, 255)` | `#0000FF` | Information, primary data | -| Orange | `(255, 165, 0)` | `#FFA500` | In progress, warnings | -| Yellow | `(255, 255, 0)` | `#FFFF00` | Pending, caution | -| Purple | `(128, 0, 128)` | `#800080` | Special, VIP, custom | -| Light Blue | `(173, 216, 230)` | `#ADD8E6` | Secondary, reference | -| Light Green | `(144, 238, 144)` | `#90EE90` | Safe, verified | -| Gray | `(128, 128, 128)` | `#808080` | Inactive, archived | -| Pink | `(255, 192, 203)` | `#FFC0CB` | Special attention | - -See [Common Color Presets](#common-color-presets) for complete list. - -### 2. Visibility Intent Detection -Parse user intent and map to correct action: - -``` -User says "hide calculations/formulas/templates" - → Call: very-hide (VeryHidden) - → Reason: Internal sheets shouldn't be user-accessible - -User says "hide for now/temporarily" - → Call: hide (Hidden) - → Reason: User may need to access later - -User says "protect/secure sensitive data" - → Call: very-hide (VeryHidden) - → Reason: Security requirement - -User says "show/unhide/make visible" - → Call: show (Visible) - → Reason: Make accessible -``` - -### 3. Batch vs Single Operations -Optimize performance based on operation count: - -``` -Coloring/hiding 3+ sheets - → Use: Batch mode (begin_excel_batch → operations → commit_excel_batch) - → Benefit: ~5-6x faster (one file save vs N saves) - -Single sheet operation - → Use: Direct action call - → Benefit: Simpler, adequate for single operation - -Mixed operations (create + color + hide) - → Use: Batch mode - → Benefit: Transactional consistency -``` - -### 4. Integration with Other Commands -Chain operations for complete workflows: - -``` -When creating new sheet: - 1. worksheet(action: "create", sheetName: "Sales") - 2. worksheet(action: "set-tab-color", sheetName: "Sales", red: 0, green: 176, blue: 240) - → Result: New sheet with color applied immediately - -When organizing workbook: - 1. Color code by category (batch operation) - 2. Hide internal sheets (very-hide templates/calculations) - 3. Set visibility (hide archives) - → Result: Professional, organized workbook - -Before deleting sheet: - 1. Check if it's the last visible sheet (get-visibility on all sheets) - 2. If last visible, don't delete (error prevention) - → Result: Avoid Excel error -``` - -### 5. Error Prevention Strategies -Validate before calling: - -``` -RGB color validation: - if (red < 0 || red > 255 || green < 0 || green > 255 || blue < 0 || blue > 255) { - → Error: "RGB values must be 0-255" - } - -Sheet existence check: - 1. Call: worksheet(action: "list") - 2. Verify sheetName exists in list - 3. Then call: set-tab-color or set-visibility - → Prevents "sheet not found" errors - -Last visible sheet protection: - 1. Get visibility of all sheets - 2. Count visible sheets - 3. If count === 1 and attempting to hide that sheet: - → Error: "Cannot hide last visible sheet" -``` - ---- - -## CLI Commands (Secondary Use Case) - -### Tab Color Commands - -**sheet-set-tab-color** - Set worksheet tab color -```powershell -excelcli sheet-set-tab-color <file.xlsx> <sheet-name> <red> <green> <blue> - -# Examples -excelcli sheet-set-tab-color "Report.xlsx" "Sales" 255 0 0 # Red -excelcli sheet-set-tab-color "Report.xlsx" "Expenses" 0 255 0 # Green -excelcli sheet-set-tab-color "Report.xlsx" "Summary" 0 0 255 # Blue -excelcli sheet-set-tab-color "Report.xlsx" "Data" 255 165 0 # Orange -``` - -**sheet-get-tab-color** - Get worksheet tab color -```powershell -excelcli sheet-get-tab-color <file.xlsx> <sheet-name> - -# Example output -Sheet: Sales -Color: #FF0000 (Red: 255, Green: 0, Blue: 0) -``` - -**sheet-clear-tab-color** - Clear worksheet tab color -```powershell -excelcli sheet-clear-tab-color <file.xlsx> <sheet-name> - -# Example -excelcli sheet-clear-tab-color "Report.xlsx" "Sales" -``` - -### Visibility Commands - -**sheet-set-visibility** - Set worksheet visibility level -```powershell -excelcli sheet-set-visibility <file.xlsx> <sheet-name> <visible|hidden|veryhidden> - -# Examples -excelcli sheet-set-visibility "Report.xlsx" "Data" hidden -excelcli sheet-set-visibility "Report.xlsx" "Data" veryhidden -excelcli sheet-set-visibility "Report.xlsx" "Data" visible -``` - -**sheet-get-visibility** - Get worksheet visibility level -```powershell -excelcli sheet-get-visibility <file.xlsx> <sheet-name> - -# Example output -Sheet: Data -Visibility: Hidden -``` - -**sheet-show** - Show a hidden worksheet -```powershell -excelcli sheet-show <file.xlsx> <sheet-name> - -# Example -excelcli sheet-show "Report.xlsx" "Data" -``` - -**sheet-hide** - Hide a worksheet (user can unhide via UI) -```powershell -excelcli sheet-hide <file.xlsx> <sheet-name> - -# Example -excelcli sheet-hide "Report.xlsx" "Data" -``` - -**sheet-very-hide** - Very hide a worksheet (requires code to unhide) -```powershell -excelcli sheet-very-hide <file.xlsx> <sheet-name> - -# Example -excelcli sheet-very-hide "Report.xlsx" "Calculations" -``` - ---- - -## Testing Strategy - -### Unit Tests -- RGB to BGR conversion logic -- Enum mapping for SheetVisibility -- Input validation (RGB range 0-255) - -### Integration Tests - -**Tab Color Tests:** -- Test setting valid RGB values and verify color is set correctly -- Test RGB to BGR conversion accuracy -- Test clearing tab color removes color -- Test getting color when none is set returns HasColor = false -- Test invalid RGB values (< 0 or > 255) return error - -**Visibility Tests:** -- Test setting each visibility level (Visible, Hidden, VeryHidden) -- Test getting visibility returns correct state -- Test VeryHidden can be unhidden programmatically -- Test convenience methods (Show, Hide, VeryHide) call SetVisibility correctly - ---- - -## Breaking Changes - -**None.** All new functionality is additive to existing SheetCommands. - ---- - -## Implementation Checklist - -### Phase 1: Core Implementation -- [ ] Add `SheetVisibility` enum to Core -- [ ] Add `TabColorResult` and `SheetVisibilityResult` classes -- [ ] Update `ISheetCommands` interface with new methods -- [ ] Implement tab color operations in `SheetCommands.cs` -- [ ] Implement visibility operations in `SheetCommands.cs` -- [ ] Add integration tests for tab color -- [ ] Add integration tests for visibility - -### Phase 2: CLI Implementation -- [ ] Create CLI wrapper for tab color commands -- [ ] Create CLI wrapper for visibility commands -- [ ] Add tab color commands to `Program.cs` routing -- [ ] Add visibility commands to `Program.cs` routing -- [ ] Add CLI tests for new commands -- [ ] Update user documentation - -### Phase 3: MCP Server Implementation -- [ ] Add tab color actions to `ExcelWorksheetTool` -- [ ] Add visibility actions to `ExcelWorksheetTool` -- [ ] Update `server.json` configuration -- [ ] Add MCP integration tests -- [ ] Update MCP prompts documentation - -### Phase 4: Documentation -- [ ] Update README.md with new features -- [ ] Update copilot instructions -- [ ] Add examples to documentation -- [ ] Update INSTALLATION.md if needed - ---- - -## Success Criteria - -- [ ] All 8 new Core methods implemented and tested -- [ ] RGB ↔ BGR conversion working correctly -- [ ] All 3 visibility levels (Visible, Hidden, VeryHidden) working -- [ ] CLI commands functional for both features -- [ ] MCP Server actions working via protocol -- [ ] Integration tests passing (95%+ coverage) -- [ ] Documentation complete - ---- - -## Common Color Presets - -For user convenience, here are common Excel tab colors: - -| Color Name | RGB | Hex | BGR (Excel) | -|------------|-----|-----|-------------| -| Red | 255, 0, 0 | #FF0000 | 0x0000FF | -| Green | 0, 255, 0 | #00FF00 | 0x00FF00 | -| Blue | 0, 0, 255 | #0000FF | 0xFF0000 | -| Yellow | 255, 255, 0 | #FFFF00 | 0x00FFFF | -| Orange | 255, 165, 0 | #FFA500 | 0x00A5FF | -| Purple | 128, 0, 128 | #800080 | 0x800080 | -| Pink | 255, 192, 203 | #FFC0CB | 0xCBC0FF | -| Teal | 0, 128, 128 | #008080 | 0x808000 | -| Light Blue | 173, 216, 230 | #ADD8E6 | 0xE6D8AD | -| Light Green | 144, 238, 144 | #90EE90 | 0x90EE90 | - -**Note:** BGR values shown for reference. API accepts RGB values and handles conversion internally. - ---- - -## Future Enhancements (Out of Scope) - -- **Hex Color Input** - Accept `#FF0000` format directly (currently requires RGB conversion) -- **Get All Colors Action** - Single call to retrieve all sheet colors: `get-all-tab-colors` → `[{sheetName, red, green, blue, hexColor}]` -- **Theme Color Support** - Use Excel's theme colors instead of RGB -- **Tab Icons** - Excel 365 supports custom tab icons (not widely used) -- **Tab Position** - Reorder tabs programmatically -- **Tab Group Protection** - Protect groups of tabs together -- **Bulk Color Operations** - Set same color for multiple sheets in one call - -These features can be considered in future iterations if user demand exists. diff --git a/specs/TABLE-API-SPECIFICATION.md b/specs/TABLE-API-SPECIFICATION.md deleted file mode 100644 index 0948d3c5..00000000 --- a/specs/TABLE-API-SPECIFICATION.md +++ /dev/null @@ -1,503 +0,0 @@ -# Excel Table (ListObject) API Specification - -> **Comprehensive specification for Excel Table operations - reviewing current implementation and future refactoring needs** - -## Executive Summary - -This specification reviews the **current TableCommands implementation** to determine: -1. What functionality already exists -2. What overlaps with RangeCommands -3. What should be refactored or removed -4. What's missing that should be added - -### Key Questions to Answer - -1. **Does TableCommands duplicate RangeCommands?** - Data operations on tables -2. **What's the proper division of responsibilities?** - Table structure vs data operations -3. **Should ReadDataAsync/AppendRowsAsync move to RangeCommands?** - Data operations -4. **What table-specific features are missing?** - Structured references, filters, slicers? - ---- - -## Current TableCommands Implementation - -### Interface Review (ITableCommands.cs) - -**Lifecycle Operations:** -- ✅ `List` - List all tables in workbook -- ✅ `Create` - Create table from range with headers/style -- ✅ `Rename` - Rename table -- ✅ `Delete` - Delete table (convert back to range) -- ✅ `GetInfo` - Get detailed table information - -**Structure Operations:** -- ✅ `Resize` - Resize table to new range -- ✅ `ToggleTotals` - Show/hide totals row -- ✅ `SetColumnTotal` - Set totals function for column -- ✅ `SetStyle` - Change table style - -**Data Operations:** ⚠️ **POTENTIAL OVERLAP WITH RANGECOMMANDS** -- ✅ `ReadData` - Read table data -- ✅ `AppendRows` - Append rows to table - -**Data Model Integration:** -- ✅ `AddToDataModelAsync` - Add table to Power Pivot - ---- - -## Excel Table (ListObject) Capabilities - -### What is an Excel Table? - -Excel Tables (ListObject COM objects) are **structured ranges with metadata**: -- Named references (e.g., "SalesTable") -- Column headers with names -- Automatic expansion when data added -- Built-in filtering and sorting UI -- Table styles and formatting -- Totals row with aggregate functions -- Structured references in formulas (`[@ColumnName]`) -- Can be added to Data Model for relationships - -### Excel COM API - ListObject Operations - -#### 1. **Table Lifecycle** -```csharp -// Create table -dynamic listObjects = sheet.ListObjects; -dynamic table = listObjects.Add( - SourceType: xlSrcRange, - Source: sheet.Range["A1:D100"], - XlListObjectHasHeaders: xlYes -); -table.Name = "SalesTable"; - -// Delete table (convert to range, preserve data) -table.Unlist(); - -// Delete table (remove everything) -table.Delete(); -``` - -#### 2. **Table Properties** -```csharp -// Basic properties -string name = table.Name; -string range = table.Range.Address; -bool hasHeaders = table.ShowHeaders; -bool hasTotals = table.ShowTotals; -string style = table.TableStyle.Name; - -// Row counts -int totalRows = table.Range.Rows.Count; // Including header/totals -int dataRows = table.DataBodyRange?.Rows.Count ?? 0; // Data only - -// Column operations -int columnCount = table.ListColumns.Count; -dynamic column = table.ListColumns.Item(1); // or by name -string columnName = column.Name; -``` - -#### 3. **Table Resize** -```csharp -// Resize table to new range -table.Resize(sheet.Range["A1:E200"]); -``` - -#### 4. **Totals Row** -```csharp -// Show/hide totals row -table.ShowTotals = true; - -// Set totals function for column -dynamic column = table.ListColumns.Item("Amount"); -column.TotalsCalculation = xlTotalsCalculationSum; // Sum -column.TotalsCalculation = xlTotalsCalculationAverage; // Average -column.TotalsCalculation = xlTotalsCalculationCount; // Count -column.TotalsCalculation = xlTotalsCalculationMax; // Max -column.TotalsCalculation = xlTotalsCalculationMin; // Min -column.TotalsCalculation = xlTotalsCalculationStdDev; // Std Dev -column.TotalsCalculation = xlTotalsCalculationVar; // Variance -column.TotalsCalculation = xlTotalsCalculationCustom; // Custom formula -``` - -#### 5. **Table Styles** -```csharp -// Built-in styles -table.TableStyle = workbook.TableStyles.Item("TableStyleMedium2"); - -// Or by name -table.TableStyle = "TableStyleLight9"; -``` - -#### 6. **AutoFilter (Filtering)** -```csharp -// Tables automatically have AutoFilter -dynamic autoFilter = table.AutoFilter; - -// Apply filter to column -autoFilter.Range.AutoFilter( - Field: 2, // Column index (1-based) - Criteria1: "USA", - Operator: xlFilterValues -); - -// Clear filters -autoFilter.ShowAllData(); - -// Check if filtered -bool isFiltered = table.ShowAutoFilter; -``` - -#### 7. **Data Operations** -```csharp -// Read data (values only, no headers) -dynamic dataBodyRange = table.DataBodyRange; -object[,] values = dataBodyRange?.Value2; - -// Read entire table (including headers) -object[,] allData = table.Range.Value2; - -// Append row (table auto-expands) -dynamic newRow = table.ListRows.Add(); -newRow.Range.Value2 = new object[,] { { val1, val2, val3 } }; - -// Insert row at position -dynamic insertedRow = table.ListRows.Add(Position: 5); -``` - -#### 8. **Data Model Integration** -```csharp -// Add to Data Model (Power Pivot) -table.TableObject = table; // Make it a "proper" table -// Then use Power Pivot to add to model -``` - ---- - -## Overlap Analysis: TableCommands vs RangeCommands - -### Current Overlap - -| Operation | TableCommands | RangeCommands | Verdict | -|-----------|--------------|---------------|---------| -| **Read data** | `ReadDataAsync` | `GetValuesAsync` | ⚠️ OVERLAP - RangeCommands can read table ranges | -| **Write data** | `AppendRowsAsync` | `SetValuesAsync` | ⚠️ OVERLAP - RangeCommands can write to table ranges | -| **Clear data** | ❌ Not implemented | `ClearContentsAsync` | ✅ RangeCommands handles this | -| **Format cells** | ❌ Not implemented | `SetNumberFormatAsync`, `SetFontAsync`, etc. | ✅ RangeCommands handles this | - -### Key Insight: Tables ARE Ranges with Metadata - -Excel Tables are fundamentally **ranges with additional structure**: -- Underlying cells = regular range -- Table structure = metadata layer (headers, totals, style, filters) - -**Proposed Division:** -- **TableCommands** = Table **structure and metadata** (lifecycle, totals, filters, styles) -- **RangeCommands** = **Data operations** on any range (including table data ranges) - ---- - -## Proposed Refactoring Strategy - -### Option 1: Remove Data Operations from TableCommands - -**Remove from TableCommands:** -- ❌ `ReadDataAsync` → Use `RangeCommands.GetValuesAsync(batch, sheetName, "TableName[#Data]")` -- ❌ `AppendRowsAsync` → Use `RangeCommands.SetValuesAsync` + `ResizeAsync` - -**Keep in TableCommands:** -- ✅ All lifecycle operations (List, Create, Rename, Delete, GetInfo) -- ✅ All structure operations (Resize, ToggleTotals, SetColumnTotal, SetStyle) -- ✅ Data Model integration (AddToDataModel) -- ✅ Filter operations (if added) - -**Benefits:** -- Clear separation: Table structure vs data operations -- Users learn ONE API for data (RangeCommands) -- TableCommands focuses on table-specific features - -**Challenges:** -- Users need to know table structured references (`TableName[#Data]`) -- Auto-expansion on append requires manual resize - -### Option 2: Keep Data Operations but Delegate to RangeCommands Internally - -**Keep current interface:** -- ✅ `ReadDataAsync` - Internally calls RangeCommands -- ✅ `AppendRowsAsync` - Internally calls RangeCommands + auto-resize - -**Benefits:** -- User-friendly API (no need to know structured references) -- Auto-expansion handled automatically -- Backwards compatible - -**Challenges:** -- Duplication of functionality -- Two ways to do the same thing - -### Option 3: Hybrid Approach (RECOMMENDED) - -**TableCommands focuses on table-specific operations:** -- ✅ Lifecycle: List, Create, Rename, Delete, GetInfo -- ✅ Structure: Resize, ToggleTotals, SetColumnTotal, SetStyle -- ✅ Table-specific data: `AppendRows` (auto-expansion feature) -- ✅ Filters: Apply, clear, get filter state -- ✅ Data Model: AddToDataModel -- ❌ **Remove**: `ReadData` - Use RangeCommands instead - -**Rationale:** -- `AppendRows` has table-specific behavior (auto-expansion) - KEEP -- `ReadData` is just range read with no table-specific logic - REMOVE -- Filters are table-specific (AutoFilter object) - ADD -- Data operations (format, copy, etc.) - Use RangeCommands - ---- - -## Missing Table Features - -### 1. **Filter Operations** ⭐ HIGH PRIORITY -```csharp -// Apply filter to column -Task<OperationResult> ApplyFilterAsync(IExcelBatch batch, string tableName, string columnName, string criteria); - -// Apply multiple criteria filter -Task<OperationResult> ApplyFilterAsync(IExcelBatch batch, string tableName, string columnName, List<string> values); - -// Clear filters -Task<OperationResult> ClearFiltersAsync(IExcelBatch batch, string tableName); - -// Get filter state -Task<TableFilterResult> GetFiltersAsync(IExcelBatch batch, string tableName); -``` - -**Excel COM:** -```csharp -dynamic autoFilter = table.AutoFilter; -autoFilter.Range.AutoFilter(Field: 2, Criteria1: "USA"); -autoFilter.ShowAllData(); // Clear all filters -``` - -### 2. **Structured Reference Support** ⭐ MEDIUM PRIORITY -```csharp -// Get structured reference for table regions -Task<OperationResult> GetStructuredReferenceAsync(IExcelBatch batch, string tableName, TableRegion region); - -public enum TableRegion -{ - All, // TableName[#All] - Data, // TableName[#Data] - Headers, // TableName[#Headers] - Totals, // TableName[#Totals] - ThisRow // TableName[@] -} -``` - -### 3. **Column Operations** ⭐ MEDIUM PRIORITY -```csharp -// Add column to table -Task<OperationResult> AddColumnAsync(IExcelBatch batch, string tableName, string columnName, int? position = null); - -// Remove column from table -Task<OperationResult> RemoveColumnAsync(IExcelBatch batch, string tableName, string columnName); - -// Rename column -Task<OperationResult> RenameColumnAsync(IExcelBatch batch, string tableName, string oldName, string newName); -``` - -**Excel COM:** -```csharp -dynamic newColumn = table.ListColumns.Add(Position: 3); -newColumn.Name = "NewColumn"; -table.ListColumns.Item("OldColumn").Delete(); -``` - -### 4. **Sort Operations** ⭐ LOW PRIORITY (RangeCommands has Sort) -Tables can use standard Range.Sort(), so RangeCommands.SortAsync works on table ranges. - -### 5. **Data Validation on Columns** ⭐ LOW PRIORITY (RangeCommands has Validation) -RangeCommands validation operations work on table column ranges. - -### 6. **Slicers** ✅ IMPLEMENTED - -Table slicers provide visual filtering controls for Excel Tables: - -```csharp -// Create slicer for table column -SlicerResult CreateTableSlicer(IExcelBatch batch, string tableName, - string columnName, string slicerName, string destinationSheet, string position); - -// List all Table slicers (optionally filter by table name) -SlicerListResult ListTableSlicers(IExcelBatch batch, string? tableName = null); - -// Set slicer selection (filter values) -SlicerResult SetTableSlicerSelection(IExcelBatch batch, string slicerName, - List<string> selectedItems, bool clearFirst = true); - -// Delete a Table slicer -OperationResult DeleteTableSlicer(IExcelBatch batch, string slicerName); -``` - -**Key Implementation Details:** -- Table slicers use `SlicerCaches.Add(table, columnName)` (deprecated but required for non-OLAP sources) -- Detection uses `SlicerCache.List` boolean property (returns `true` for Table slicers) -- Cannot use `SlicerCaches.Add2()` for Table slicers - it only supports PivotTable sources -- Exposed via `slicer` MCP tool with `create-table-slicer`, `list-table-slicers`, etc. - ---- - -## Recommended TableCommands Refactoring - -### Phase 1: Remove Duplication (THIS PHASE) - -**Remove from TableCommands:** -1. ❌ `ReadDataAsync` - Users should use `RangeCommands.GetValuesAsync(batch, sheetName, "TableName[#Data]")` - - Document migration: "Use RangeCommands to read table data" - - Provide examples in docs - -**Keep in TableCommands:** -2. ✅ `AppendRowsAsync` - Table-specific auto-expansion behavior - - This is unique to tables (auto-resize when data added) - - Cannot be easily replicated with RangeCommands alone - -**Update Documentation:** -3. Document that RangeCommands works with table ranges -4. Provide examples of table structured references - -### Phase 2: Add Missing Features (FUTURE) - -**Filter Operations:** -1. `ApplyFilterAsync` - Apply filter to column -2. `ClearFiltersAsync` - Clear all filters -3. `GetFiltersAsync` - Get current filter state - -**Column Operations:** -4. `AddColumnAsync` - Add column to table -5. `RemoveColumnAsync` - Remove column -6. `RenameColumnAsync` - Rename column - -**Structured References:** -7. `GetStructuredReferenceAsync` - Get range address for table regions - ---- - -## Implementation Details - -### Current TableCommands Methods Review - -#### ✅ KEEP - Table Lifecycle -- `ListAsync` - List all tables -- `CreateAsync` - Create table from range -- `RenameAsync` - Rename table -- `DeleteAsync` - Delete table -- `GetInfoAsync` - Get table details - -#### ✅ KEEP - Table Structure -- `ResizeAsync` - Resize table -- `ToggleTotalsAsync` - Show/hide totals row -- `SetColumnTotalAsync` - Set totals function -- `SetStyleAsync` - Change table style - -#### ✅ KEEP - Table-Specific Data -- `AppendRowsAsync` - Append with auto-expansion - -#### ✅ KEEP - Data Model -- `AddToDataModelAsync` - Add to Power Pivot - -#### ❌ REMOVE - Data Operations (Use RangeCommands) -- `ReadDataAsync` - Duplicate of RangeCommands.GetValuesAsync - ---- - -## Migration Guide for Users - -### Before (TableCommands.ReadDataAsync) -```csharp -var result = await tableCommands.ReadDataAsync(batch, "SalesTable"); -List<List<object?>> data = result.Data; -``` - -### After (RangeCommands.GetValuesAsync) -```csharp -// Option 1: Read data only (no headers) -var result = await rangeCommands.GetValuesAsync(batch, "Sales", "SalesTable[#Data]"); -List<List<object?>> data = result.Values; - -// Option 2: Read everything (headers + data) -var result = await rangeCommands.GetValuesAsync(batch, "Sales", "SalesTable[#All]"); - -// Option 3: If you don't know the sheet name -var tableInfo = await tableCommands.GetInfoAsync(batch, "SalesTable"); -var result = await rangeCommands.GetValuesAsync(batch, tableInfo.SheetName, "SalesTable[#Data]"); -``` - -### Table Structured References - -Excel Tables support structured references: -- `TableName[#All]` - Entire table including headers and totals -- `TableName[#Data]` - Data rows only (no headers or totals) -- `TableName[#Headers]` - Header row only -- `TableName[#Totals]` - Totals row only -- `TableName[[ColumnName]]` - Specific column -- `TableName[@]` - Current row (in formulas) - ---- - -## Summary - -### Current State -- TableCommands has 12 operations -- 1 operation (`ReadDataAsync`) duplicates RangeCommands -- 1 operation (`AppendRowsAsync`) has table-specific behavior worth keeping -- Missing important table features: filters, column management - -### Proposed Changes - -**Phase 1 - Remove Duplication:** -1. ❌ Delete `ReadDataAsync` - Use RangeCommands instead -2. ✅ Keep `AppendRowsAsync` - Table-specific auto-expansion -3. 📝 Update documentation with migration guide and structured reference examples - -**Phase 2 - Add Missing Features (Future):** -4. Add filter operations (Apply, Clear, Get) -5. Add column operations (Add, Remove, Rename) -6. Add structured reference helper - -### Architecture Principle - -**TableCommands** = Table **structure and metadata** -- Lifecycle (create, rename, delete, list) -- Structure (resize, totals, styles, columns) -- Table-specific behaviors (auto-expansion on append, filters) -- Data Model integration -- **Table slicers** ✅ IMPLEMENTED - -**RangeCommands** = **Data operations** on any range -- Read/write values and formulas -- Formatting and styling -- Copy, clear, insert, delete -- Works on table ranges via structured references - -This maintains clear separation of concerns and prevents duplication! - ---- - -## Open Questions for Review - -1. **Should we remove `ReadDataAsync` in Phase 1?** - - Pro: Eliminates duplication, encourages unified API - - Con: Breaking change, users need to learn structured references - -2. **Should `AppendRowsAsync` accept 2D arrays instead of CSV?** - - Pro: Consistent with RangeCommands (no CSV in Core/MCP) - - Con: Breaking change - -3. **Should we add filter operations in Phase 1 or Phase 2?** - - Filters are common table operations - - But adds scope to refactoring - -4. ~~**Should we support table slicers?**~~ ✅ **DONE - Implemented in Issue #363** - - Table slicers implemented via `ITableCommands` interface - - Exposed via `slicer` MCP tool - -**Next Step:** Review this specification and decide on Phase 1 scope before implementation! diff --git a/src/ExcelMcp.CLI/Resources/excelcli.ico b/src/ExcelMcp.CLI/Resources/excelcli.ico deleted file mode 100644 index a52e5f3c..00000000 Binary files a/src/ExcelMcp.CLI/Resources/excelcli.ico and /dev/null differ diff --git a/src/ExcelMcp.ComInterop/ComInteropConstants.cs b/src/ExcelMcp.ComInterop/ComInteropConstants.cs deleted file mode 100644 index d629730f..00000000 --- a/src/ExcelMcp.ComInterop/ComInteropConstants.cs +++ /dev/null @@ -1,81 +0,0 @@ -namespace Sbroenne.ExcelMcp.ComInterop; - -/// <summary> -/// Constants for Excel COM interop operations. -/// </summary> -public static class ComInteropConstants -{ - #region Timeouts - - /// <summary> - /// Timeout for Excel.Quit() operation (30 seconds). - /// With DisplayAlerts=false, Excel quits quickly. This timeout catches hung scenarios. - /// </summary> - public static readonly TimeSpan ExcelQuitTimeout = TimeSpan.FromSeconds(30); - - /// <summary> - /// Timeout for STA thread join after quit. - /// CRITICAL: Must be >= ExcelQuitTimeout to ensure Dispose() waits for CloseAndQuit() to complete. - /// Set to ExcelQuitTimeout + 15s margin for workbook close and COM cleanup. - /// </summary> - public static readonly TimeSpan StaThreadJoinTimeout = ExcelQuitTimeout + TimeSpan.FromSeconds(15); - - /// <summary> - /// Timeout for save operations (5 minutes). - /// Large files with Power Query or Data Model may take longer to save. - /// </summary> - public static readonly TimeSpan SaveOperationTimeout = TimeSpan.FromMinutes(5); - - /// <summary> - /// Default timeout for individual Excel operations (5 minutes). - /// Most operations complete in under 30 seconds, but this provides buffer for slow machines. - /// Can be overridden when creating a session via timeoutSeconds parameter. - /// </summary> - public static readonly TimeSpan DefaultOperationTimeout = TimeSpan.FromMinutes(5); - - /// <summary> - /// Default timeout for data loading operations (30 minutes). - /// Used by Power Query refresh/load-to and connection refresh/load-to. - /// Heavy workloads (Folder.Files, multi-query, large OLEDB) can take 10+ minutes. - /// </summary> - public static readonly TimeSpan DataOperationTimeout = TimeSpan.FromMinutes(30); - - /// <summary> - /// Maximum wait time for session creation file lock acquisition (5 seconds). - /// </summary> - public static readonly TimeSpan SessionFileLockTimeout = TimeSpan.FromSeconds(5); - - #endregion - - #region Sleep Intervals - - /// <summary> - /// Delay between file lock acquisition retries (100ms). - /// </summary> - public const int FileLockRetryDelayMs = 100; - - /// <summary> - /// Delay between session lock acquisition retries (200ms). - /// </summary> - public const int SessionLockRetryDelayMs = 200; - - #endregion - - #region Excel File Formats - - /// <summary> - /// Excel Open XML Workbook format code (.xlsx). - /// XlFileFormat.xlOpenXMLWorkbook = 51 - /// </summary> - public const int XlOpenXmlWorkbook = 51; - - /// <summary> - /// Excel Open XML Macro-Enabled Workbook format code (.xlsm). - /// XlFileFormat.xlOpenXMLWorkbookMacroEnabled = 52 - /// </summary> - public const int XlOpenXmlWorkbookMacroEnabled = 52; - - #endregion -} - - diff --git a/src/ExcelMcp.ComInterop/ComUtilities.cs b/src/ExcelMcp.ComInterop/ComUtilities.cs deleted file mode 100644 index aa274761..00000000 --- a/src/ExcelMcp.ComInterop/ComUtilities.cs +++ /dev/null @@ -1,396 +0,0 @@ -using System.Runtime.InteropServices; -using Excel = Microsoft.Office.Interop.Excel; - -namespace Sbroenne.ExcelMcp.ComInterop; - -/// <summary> -/// Low-level COM interop utilities for Excel automation. -/// Provides helpers for finding Excel objects and managing COM object lifecycle. -/// </summary> -public static class ComUtilities -{ - /// <summary> - /// Safely releases a COM object and sets the reference to null - /// </summary> - /// <param name="comObject">The COM object to release</param> - /// <remarks> - /// Use this helper to release intermediate COM objects (like ranges, worksheets, queries) - /// to prevent Excel process from staying open. This is especially important when - /// iterating through collections or accessing multiple COM properties. - /// </remarks> - /// <example> - /// <code> - /// dynamic? queries = null; - /// try - /// { - /// queries = workbook.Queries; - /// // Use queries... - /// } - /// finally - /// { - /// ComUtilities.Release(ref queries); - /// } - /// </code> - /// </example> - public static void Release<T>(ref T? comObject) where T : class - { - if (comObject != null) - { - try - { - Marshal.ReleaseComObject(comObject); - } - catch (Exception) - { - // Ignore errors during release — COM object may already be released or RPC disconnected - } - comObject = null; - } - } - - /// <summary> - /// Safely attempts to quit an Excel application COM object. - /// This is a fire-and-forget cleanup helper - errors are swallowed. - /// </summary> - /// <param name="excel">The Excel.Application COM object (dynamic)</param> - /// <remarks> - /// Use this for cleanup scenarios where you want to quit Excel but don't - /// need to handle or report errors. For production shutdown with retry - /// logic, use ExcelShutdownService.CloseAndQuit instead. - /// </remarks> - [System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "CS8602", Justification = "Dynamic COM interop - Quit exists on Excel.Application")] - public static void TryQuitExcel(Excel.Application? excel) - { - if (excel == null) return; - - try - { - excel.Quit(); - } - catch (Exception) - { - // Swallow errors during cleanup — Excel may already be gone - } - } - - /// <summary> - /// Finds a Power Query by name in the workbook - /// </summary> - /// <param name="workbook">Excel workbook COM object</param> - /// <param name="queryName">Name of the query to find</param> - /// <returns>The query COM object if found, null otherwise</returns> - /// <remarks> - /// CRITICAL: Caller is responsible for releasing the returned COM object. - /// Use ComUtilities.Release(ref query) when done with the object. - /// </remarks> - public static Excel.WorkbookQuery? FindQuery(Excel.Workbook workbook, string queryName) - { - Excel.Queries? queriesCollection = null; - try - { - queriesCollection = workbook.Queries; - int count = queriesCollection.Count; - for (int i = 1; i <= count; i++) - { - Excel.WorkbookQuery? query = null; - try - { - query = queriesCollection.Item(i); - string currentName = query.Name; - - if (currentName == queryName) - { - // Found match - return it (caller owns it now) - var result = query; - query = null; // Prevent cleanup in finally block - return result; - } - } - finally - { - // Only release if not returning (query will be null if we're returning it) - if (query != null) - { - Release(ref query); - } - } - } - - return null; // Not found - } - catch (Exception ex) - { - // Log or rethrow - don't silently swallow - throw new InvalidOperationException($"Failed to search for Power Query '{queryName}'.", ex); - } - finally - { - Release(ref queriesCollection); - } - } - - /// <summary> - /// Finds a named range by name in the workbook - /// </summary> - /// <param name="workbook">Excel workbook COM object</param> - /// <param name="name">Name of the named range to find</param> - /// <returns>The named range COM object if found, null otherwise</returns> - /// <remarks> - /// CRITICAL: Caller is responsible for releasing the returned COM object. - /// Use ComUtilities.Release(ref nameObj) when done with the object. - /// </remarks> - public static Excel.Name? FindName(Excel.Workbook workbook, string name) - { - Excel.Names? namesCollection = null; - try - { - namesCollection = workbook.Names; - int count = namesCollection.Count; - for (int i = 1; i <= count; i++) - { - Excel.Name? nameObj = null; - try - { - nameObj = (Excel.Name)namesCollection.Item(i); - string currentName = nameObj.Name; - - if (currentName == name) - { - // Found match - return it (caller owns it now) - var result = nameObj; - nameObj = null; // Prevent cleanup in finally block - return result; - } - } - finally - { - // Only release if not returning - if (nameObj != null) - { - Release(ref nameObj); - } - } - } - - return null; // Not found - } - catch (Exception ex) - { - throw new InvalidOperationException($"Failed to search for named range '{name}'.", ex); - } - finally - { - Release(ref namesCollection); - } - } - - /// <summary> - /// Finds a worksheet by name in the workbook - /// </summary> - /// <param name="workbook">Excel workbook COM object</param> - /// <param name="sheetName">Name of the worksheet to find</param> - /// <returns>The worksheet COM object if found, null otherwise</returns> - /// <remarks> - /// CRITICAL: Caller is responsible for releasing the returned COM object. - /// Use ComUtilities.Release(ref sheet) when done with the object. - /// </remarks> - public static Excel.Worksheet? FindSheet(Excel.Workbook workbook, string sheetName) - { - Excel.Sheets? sheetsCollection = null; - try - { - sheetsCollection = workbook.Worksheets; - int count = sheetsCollection.Count; - for (int i = 1; i <= count; i++) - { - Excel.Worksheet? sheet = null; - try - { - sheet = (Excel.Worksheet)sheetsCollection[i]; - string currentName = sheet.Name; - - if (currentName == sheetName) - { - // Found match - return it (caller owns it now) - var result = sheet; - sheet = null; // Prevent cleanup in finally block - return result; - } - } - finally - { - // Only release if not returning - if (sheet != null) - { - Release(ref sheet); - } - } - } - - return null; // Not found - } - catch (Exception ex) - { - throw new InvalidOperationException($"Failed to search for worksheet '{sheetName}'.", ex); - } - finally - { - Release(ref sheetsCollection); - } - } - - /// <summary> - /// Finds a connection in the workbook by name (case-insensitive) - /// </summary> - /// <param name="workbook">Excel workbook COM object</param> - /// <param name="connectionName">Name of the connection to find</param> - /// <returns>Connection object if found, null otherwise</returns> - /// <remarks> - /// CRITICAL: Caller is responsible for releasing the returned COM object. - /// Use ComUtilities.Release(ref connection) when done with the object. - /// </remarks> - public static Excel.WorkbookConnection? FindConnection(Excel.Workbook workbook, string connectionName) - { - Excel.Connections? connections = null; - Excel.WorkbookConnection? conn = null; - - try - { - connections = workbook.Connections; - - for (int i = 1; i <= connections.Count; i++) - { - conn = connections.Item(i); - string name = conn.Name ?? ""; - - // Match exact name or "Query - Name" pattern (Power Query connections) - if (name.Equals(connectionName, StringComparison.OrdinalIgnoreCase) || - name.Equals($"Query - {connectionName}", StringComparison.OrdinalIgnoreCase)) - { - // Found match - return it (caller owns it now) - var result = conn; - conn = null; // Prevent cleanup in finally block - return result; - } - - // Not a match - release before next iteration - Release(ref conn); - } - - return null; // Not found - } - catch (Exception ex) - { - throw new InvalidOperationException($"Failed to search for connection '{connectionName}'.", ex); - } - finally - { - // Clean up any unreleased connection from last iteration - if (conn != null) - { - Release(ref conn); - } - Release(ref connections); - } - } - - /// <summary> - /// Safely iterates through all columns in a model table with automatic COM cleanup - /// </summary> - /// <param name="table">Model table COM object</param> - /// <param name="action">Action to perform on each column (receives column and 1-based index)</param> - public static void ForEachColumn(Excel.ModelTable table, Action<Excel.ModelTableColumn, int> action) - { - Excel.ModelTableColumns? columns = null; - try - { - columns = table.ModelTableColumns; - int count = columns.Count; - - for (int i = 1; i <= count; i++) - { - Excel.ModelTableColumn? column = null; - try - { - column = columns.Item(i); - action(column, i); - } - finally - { - Release(ref column); - } - } - } - finally - { - Release(ref columns); - } - } - - /// <summary> - /// Safely gets a string property from a COM object, returning empty string if null - /// </summary> - /// <param name="obj">COM object</param> - /// <param name="propertyName">Property name</param> - /// <returns>Property value or empty string</returns> - public static string SafeGetString(dynamic? obj, string propertyName) - { - try - { - var value = propertyName switch - { - "Name" => obj.Name, - "Formula" => obj.Formula, - "Description" => obj.Description, - "SourceName" => obj.SourceName, - _ => null - }; - return value?.ToString() ?? string.Empty; - } - catch (Exception) - { - return string.Empty; - } - } - - /// <summary> - /// Safely gets an integer property from a COM object, returning 0 if null or invalid - /// </summary> - /// <param name="obj">COM object</param> - /// <param name="propertyName">Property name</param> - /// <returns>Property value or 0</returns> - public static int SafeGetInt(dynamic? obj, string propertyName) - { - try - { - var value = propertyName switch - { - "RecordCount" => obj.RecordCount, - "Count" => obj.Count, - _ => 0 - }; - return Convert.ToInt32(value); - } - catch (Exception) - { - return 0; - } - } - - [DllImport("kernel32.dll")] - private static extern void Sleep(uint dwMilliseconds); - - /// <summary> - /// Kernel-level sleep that does NOT pump the STA COM message queue. - /// Unlike Thread.Sleep (which uses CoWaitForMultipleHandles internally and wakes early on - /// every incoming COM event), this calls Win32 Sleep() directly via NtDelayExecution — - /// the thread genuinely sleeps for the full interval regardless of COM callbacks. - /// Safe to use in WaitForRefreshCompletion: Power Query refresh completion is driven by - /// Excel's own internals (MashupHost.exe → Excel's STA). Our polling thread does not need - /// to service any callbacks for connection.Refreshing to become false. - /// </summary> - public static void KernelSleep(int milliseconds) => - Sleep((uint)Math.Max(0, milliseconds)); -} - - diff --git a/src/ExcelMcp.ComInterop/Formatting/DaxFormatter.cs b/src/ExcelMcp.ComInterop/Formatting/DaxFormatter.cs deleted file mode 100644 index f89042bb..00000000 --- a/src/ExcelMcp.ComInterop/Formatting/DaxFormatter.cs +++ /dev/null @@ -1,84 +0,0 @@ -using Dax.Formatter; - -namespace Sbroenne.ExcelMcp.ComInterop.Formatting; - -/// <summary> -/// Formats DAX (Data Analysis Expressions) code using the official Dax.Formatter library. -/// Provides automatic pretty-printing with proper indentation and line breaks. -/// </summary> -/// <remarks> -/// <para><b>Design Principles:</b></para> -/// <list type="bullet"> -/// <item>Never throws exceptions - returns original DAX on any failure</item> -/// <item>Uses official Dax.Formatter NuGet package (by SQLBI)</item> -/// <item>Gracefully handles network failures, API errors, and rate limiting</item> -/// <item>Formatting is best-effort - original DAX is always preserved if formatting fails</item> -/// </list> -/// <para><b>Usage:</b></para> -/// <code> -/// string formatted = await DaxFormatter.FormatAsync("CALCULATE(SUM(Sales[Amount]),FILTER(ALL(Calendar),Calendar[Year]=2024))"); -/// // Returns formatted DAX with indentation, or original if formatting fails -/// </code> -/// <para><b>Performance:</b></para> -/// <list type="bullet"> -/// <item>Network latency: Typically 100-500ms per API call</item> -/// <item>Singleton client instance shared across all calls for efficiency</item> -/// <item>10-second timeout prevents indefinite blocking</item> -/// <item>Graceful fallback ensures operations never fail due to formatting</item> -/// </list> -/// </remarks> -public static class DaxFormatter -{ - // Singleton instance - reused across all calls for better performance - private static readonly DaxFormatterClient _formatterClient = new(); - - /// <summary> - /// Formats DAX code using the official Dax.Formatter library. - /// </summary> - /// <param name="daxCode">The DAX code to format</param> - /// <param name="cancellationToken">Cancellation token for the HTTP request</param> - /// <returns>Formatted DAX code, or original code if formatting fails</returns> - /// <remarks> - /// This method NEVER throws exceptions. If formatting fails for any reason - /// (network error, API error, timeout, invalid DAX), it returns the original code unchanged. - /// This ensures that DAX operations never break due to formatting issues. - /// </remarks> - public static async Task<string> FormatAsync(string daxCode, CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(daxCode)) - return daxCode; - - try - { - // Use timeout wrapper for 10 second limit - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(TimeSpan.FromSeconds(10)); - - // Call official Dax.Formatter library - var response = await _formatterClient.FormatAsync(daxCode, timeoutCts.Token) - .ConfigureAwait(false); - - // Return formatted DAX if available, otherwise original - return !string.IsNullOrWhiteSpace(response?.Formatted) - ? response.Formatted - : daxCode; - } - catch (Exception) when (IsExpectedFormattingException()) - { - // Expected failures (network, timeout, parsing, etc.) - return original DAX - // This handles: HttpRequestException, TaskCanceledException, OperationCanceledException, - // JsonException, and any other API-related errors gracefully - return daxCode; - } - } - - /// <summary> - /// Filter for expected formatting exceptions. Always returns true because - /// ALL exceptions during formatting should result in graceful fallback. - /// This pattern satisfies CodeQL's generic catch clause warning while - /// maintaining the intentional catch-all behavior for formatting operations. - /// </summary> - private static bool IsExpectedFormattingException() => true; -} - - diff --git a/src/ExcelMcp.ComInterop/Formatting/DaxFormulaTranslator.cs b/src/ExcelMcp.ComInterop/Formatting/DaxFormulaTranslator.cs deleted file mode 100644 index e6af16c4..00000000 --- a/src/ExcelMcp.ComInterop/Formatting/DaxFormulaTranslator.cs +++ /dev/null @@ -1,282 +0,0 @@ -using System.Globalization; -using System.Text; - -namespace Sbroenne.ExcelMcp.ComInterop.Formatting; - -/// <summary> -/// Translates DAX formula argument separators between US (English) format (comma) and the -/// locale-specific separator that Excel's Analysis Services engine expects. -/// </summary> -/// <remarks> -/// <para><b>Why This Is Needed:</b></para> -/// <para> -/// Power Pivot's Analysis Services engine uses the SYSTEM CULTURE (not Excel's International -/// settings) to interpret DAX formula separators. This is a critical distinction because: -/// </para> -/// <list type="bullet"> -/// <item>Excel's International property may report different settings than the system culture</item> -/// <item>Power Pivot interprets commas based on the system's NumberDecimalSeparator</item> -/// <item>If the system uses comma for decimals (European locales), DAX commas must be semicolons</item> -/// </list> -/// <para><b>Example Problem (Fixed by this translator):</b></para> -/// <para> -/// On a system with culture en-DE (English with German regional settings): -/// </para> -/// <list type="bullet"> -/// <item>Excel.International reports: ListSeparator=',' DecimalSeparator='.'</item> -/// <item>System culture (en-DE) has: NumberDecimalSeparator=','</item> -/// <item>Power Pivot uses SYSTEM culture, so it sees comma as decimal separator</item> -/// <item>Formula "DATEADD(Date[Date], -1, MONTH)" becomes "DATEADD(Date[Date], -1. MONTH)" (corrupted!)</item> -/// </list> -/// <para><b>Solution:</b></para> -/// <para> -/// This translator checks the SYSTEM culture's decimal separator. If it's a comma, -/// we translate all DAX function argument commas to semicolons, regardless of what -/// Excel's International property reports. -/// </para> -/// <para><b>Usage:</b></para> -/// <code> -/// var translator = new DaxFormulaTranslator(excelApp); -/// string daxFormula = translator.TranslateToLocale("CALCULATE([ACR], DATEADD(Date[Date], -1, MONTH))"); -/// // Returns "CALCULATE([ACR]; DATEADD(Date[Date]; -1; MONTH))" on European systems -/// </code> -/// </remarks> -public sealed class DaxFormulaTranslator -{ - // XlApplicationInternational enum values (for reference, but we prefer system culture) - private const int XlListSeparator = 5; - private const int XlDecimalSeparator = 3; - - /// <summary>The Excel International list separator (for logging/diagnostics)</summary> - public string ExcelListSeparator { get; } - - /// <summary>The Excel International decimal separator (for logging/diagnostics)</summary> - public string ExcelDecimalSeparator { get; } - - /// <summary>The system culture's decimal separator - THIS is what Power Pivot uses</summary> - public string SystemDecimalSeparator { get; } - - /// <summary>The system culture's list separator (for diagnostics)</summary> - public string SystemListSeparator { get; } - - /// <summary>The separator to use for DAX function arguments. - /// Uses semicolon if system decimal separator is comma (European locales), - /// otherwise uses the Excel list separator. - /// </summary> - public string DaxArgumentSeparator { get; } - - /// <summary>True if system uses comma as decimal separator (meaning DAX commas must become semicolons)</summary> - public bool SystemUsesCommaDecimal { get; } - - /// <summary>True if translation is needed (system uses comma for decimal, so DAX commas must be semicolons)</summary> - public bool RequiresTranslation => SystemUsesCommaDecimal; - - /// <summary> - /// True if the system has an invalid configuration where both decimal and list separator are the same. - /// This is a Windows Regional Settings misconfiguration that will cause DAX errors. - /// </summary> - /// <remarks> - /// This can happen with the en-DE locale (English with German regional settings) where - /// Windows may default to comma for both decimal and list separator. The solution is to - /// change the Windows list separator to semicolon in Regional Settings > Additional settings. - /// </remarks> - public bool HasSeparatorConflict => SystemDecimalSeparator == SystemListSeparator; - - /// <summary> - /// Creates a new DaxFormulaTranslator by reading locale settings from both Excel Application - /// and the system culture. Power Pivot uses the SYSTEM culture, not Excel's International settings. - /// </summary> - /// <param name="excelApp">The Excel.Application COM object (dynamic)</param> - public DaxFormulaTranslator(dynamic excelApp) - { - // Read Excel's International settings (for logging/diagnostics) - ExcelListSeparator = GetInternationalValue(excelApp, XlListSeparator) ?? ","; - ExcelDecimalSeparator = GetInternationalValue(excelApp, XlDecimalSeparator) ?? "."; - - // CRITICAL: Power Pivot uses the SYSTEM culture, not Excel's International settings! - // Get the system culture's decimal and list separators - SystemDecimalSeparator = CultureInfo.CurrentCulture.NumberFormat.NumberDecimalSeparator; - SystemListSeparator = CultureInfo.CurrentCulture.TextInfo.ListSeparator; - SystemUsesCommaDecimal = SystemDecimalSeparator == ","; - - // Determine the correct DAX argument separator: - // - If SYSTEM uses comma for decimal, we MUST use semicolon (regardless of what Excel reports) - // - Otherwise, use the Excel list separator (which is comma for US/UK locales) - DaxArgumentSeparator = SystemUsesCommaDecimal ? ";" : ExcelListSeparator; - } - - /// <summary> - /// Translates a US (English) DAX formula to the locale-specific format Excel expects. - /// Converts comma argument separators to the locale-specific list separator. - /// </summary> - /// <param name="usDaxFormula">US DAX formula with comma separators (e.g., "DATEADD(Date[Date], -1, MONTH)")</param> - /// <returns>Locale-specific DAX formula (e.g., "DATEADD(Date[Date]; -1; MONTH)" on German Excel)</returns> - /// <remarks> - /// <para>Translation rules:</para> - /// <list type="bullet"> - /// <item>Commas inside function parentheses are translated to locale list separator</item> - /// <item>Content inside strings (single or double quotes) is preserved</item> - /// <item>Content inside square brackets (column references like [Column Name]) is preserved</item> - /// <item>Commas outside function calls are preserved (though rare in DAX)</item> - /// </list> - /// </remarks> - public string TranslateToLocale(string usDaxFormula) - { - if (string.IsNullOrEmpty(usDaxFormula)) - return usDaxFormula; - - // If system uses period as decimal (US/UK locales), no translation needed - // The comma in the formula is already the correct list separator - if (!RequiresTranslation) - return usDaxFormula; - - // Check if formula contains commas at all - if (!usDaxFormula.Contains(',')) - return usDaxFormula; - - return TranslateFormula(usDaxFormula); - } - - /// <summary> - /// Translates DAX formula by converting comma separators to locale-specific separators. - /// </summary> - private string TranslateFormula(string formula) - { - var result = new StringBuilder(formula.Length); - int parenDepth = 0; - - for (int i = 0; i < formula.Length; i++) - { - char c = formula[i]; - - // Skip content in double quotes (string literals) - if (c == '"') - { - int quoteEnd = FindClosingQuote(formula, i, '"'); - result.Append(formula.AsSpan(i, quoteEnd - i + 1)); - i = quoteEnd; - continue; - } - - // Skip content in single quotes (string literals in DAX) - if (c == '\'') - { - int quoteEnd = FindClosingQuote(formula, i, '\''); - result.Append(formula.AsSpan(i, quoteEnd - i + 1)); - i = quoteEnd; - continue; - } - - // Skip content in square brackets (column references like [Column Name, With Comma]) - if (c == '[') - { - int bracketEnd = FindClosingBracket(formula, i); - result.Append(formula.AsSpan(i, bracketEnd - i + 1)); - i = bracketEnd; - continue; - } - - // Track parentheses depth - if (c == '(') - { - parenDepth++; - result.Append(c); - continue; - } - - if (c == ')') - { - parenDepth--; - result.Append(c); - continue; - } - - // Translate commas inside function calls (parenDepth > 0) - if (c == ',' && parenDepth > 0) - { - result.Append(DaxArgumentSeparator); - continue; - } - - // All other characters pass through unchanged - result.Append(c); - } - - return result.ToString(); - } - - /// <summary> - /// Finds the closing quote character, handling escaped quotes. - /// </summary> - private static int FindClosingQuote(string formula, int startIndex, char quoteChar) - { - for (int i = startIndex + 1; i < formula.Length; i++) - { - if (formula[i] == quoteChar) - { - // Check for escaped quote (doubled quote character) - if (i + 1 < formula.Length && formula[i + 1] == quoteChar) - { - i++; // Skip the escaped quote - continue; - } - return i; - } - } - // No closing quote found - return end of string - return formula.Length - 1; - } - - /// <summary> - /// Finds the closing bracket ']', handling nested brackets. - /// </summary> - private static int FindClosingBracket(string formula, int startIndex) - { - int depth = 0; - for (int i = startIndex; i < formula.Length; i++) - { - if (formula[i] == '[') - { - depth++; - } - else if (formula[i] == ']') - { - depth--; - if (depth == 0) - return i; - } - } - // No closing bracket found - return end of string - return formula.Length - 1; - } - - /// <summary> - /// Gets a value from Excel's International property. - /// </summary> - private static string? GetInternationalValue(dynamic excelApp, int index) - { - try - { - // Access the International property with the index - // Excel COM: excelApp.International(index) returns the locale-specific value - object? value = excelApp.International[index]; - return value?.ToString(); - } - catch (Exception ex) when (ex is System.Runtime.InteropServices.COMException or Microsoft.CSharp.RuntimeBinder.RuntimeBinderException) - { - // International property access failed for this index - return null; - } - } - - /// <summary> - /// Returns a summary of the locale settings for debugging/logging. - /// </summary> - public override string ToString() - { - var conflict = HasSeparatorConflict ? " [CONFLICT: decimal=list!]" : ""; - return $"DaxFormulaTranslator: SystemDecimal='{SystemDecimalSeparator}' SystemList='{SystemListSeparator}' ExcelList='{ExcelListSeparator}' ExcelDecimal='{ExcelDecimalSeparator}' DaxArgSeparator='{DaxArgumentSeparator}' RequiresTranslation={RequiresTranslation}{conflict}"; - } -} - - diff --git a/src/ExcelMcp.ComInterop/Formatting/MCodeFormatter.cs b/src/ExcelMcp.ComInterop/Formatting/MCodeFormatter.cs deleted file mode 100644 index 75ed9a1c..00000000 --- a/src/ExcelMcp.ComInterop/Formatting/MCodeFormatter.cs +++ /dev/null @@ -1,133 +0,0 @@ -using System.Net.Http.Json; -using System.Text.Json.Serialization; - -namespace Sbroenne.ExcelMcp.ComInterop.Formatting; - -/// <summary> -/// Formats Power Query M code using the powerqueryformatter.com API. -/// Provides automatic pretty-printing with proper indentation and line breaks. -/// </summary> -/// <remarks> -/// <para><b>Design Principles:</b></para> -/// <list type="bullet"> -/// <item>Never throws exceptions - returns original M code on any failure</item> -/// <item>Uses powerqueryformatter.com API (by mogularGmbH, MIT License)</item> -/// <item>Gracefully handles network failures, API errors, and rate limiting</item> -/// <item>Formatting is best-effort - original M code is always preserved if formatting fails</item> -/// </list> -/// <para><b>Usage:</b></para> -/// <code> -/// string formatted = await MCodeFormatter.FormatAsync("let Source=Excel.CurrentWorkbook() in Source"); -/// // Returns formatted M code with indentation, or original if formatting fails -/// </code> -/// <para><b>Performance:</b></para> -/// <list type="bullet"> -/// <item>Network latency: Typically 100-500ms per API call</item> -/// <item>Singleton HttpClient instance shared across all calls for efficiency</item> -/// <item>10-second timeout prevents indefinite blocking</item> -/// <item>Graceful fallback ensures operations never fail due to formatting</item> -/// </list> -/// <para><b>API Reference:</b></para> -/// <list type="bullet"> -/// <item>Endpoint: https://m-formatter.azurewebsites.net/api/v2</item> -/// <item>Method: POST with JSON body</item> -/// <item>Source: https://github.com/mogulargmbh/m-formatter (MIT License)</item> -/// </list> -/// </remarks> -public static class MCodeFormatter -{ - private const string ApiEndpoint = "https://m-formatter.azurewebsites.net/api/v2"; - - // Singleton HttpClient - reused across all calls for better performance - // HttpClient is designed to be reused and is thread-safe - private static readonly HttpClient _httpClient = new() - { - Timeout = TimeSpan.FromSeconds(15) // Overall timeout as safety net - }; - - /// <summary> - /// Formats Power Query M code using the powerqueryformatter.com API. - /// </summary> - /// <param name="mCode">The M code to format</param> - /// <param name="cancellationToken">Cancellation token for the HTTP request</param> - /// <returns>Formatted M code, or original code if formatting fails</returns> - /// <remarks> - /// This method NEVER throws exceptions. If formatting fails for any reason - /// (network error, API error, timeout, invalid M code), it returns the original code unchanged. - /// This ensures that Power Query operations never break due to formatting issues. - /// </remarks> - public static async Task<string> FormatAsync(string mCode, CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(mCode)) - return mCode; - - try - { - // Use timeout wrapper for 10 second limit - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(TimeSpan.FromSeconds(10)); - - // Prepare request - var request = new MFormatterRequest { Code = mCode, ResultType = "text" }; - - // Call the API - var response = await _httpClient.PostAsJsonAsync(ApiEndpoint, request, timeoutCts.Token) - .ConfigureAwait(false); - - response.EnsureSuccessStatusCode(); - - // Parse response - var result = await response.Content.ReadFromJsonAsync<MFormatterResponse>(timeoutCts.Token) - .ConfigureAwait(false); - - // Return formatted M code if successful, otherwise original - return result is { Success: true } && !string.IsNullOrWhiteSpace(result.Result) - ? result.Result - : mCode; - } - catch (Exception) when (IsExpectedFormattingException()) - { - // Expected failures (network, timeout, parsing, etc.) - return original M code - // This handles: HttpRequestException, TaskCanceledException, OperationCanceledException, - // JsonException, and any other API-related errors gracefully - return mCode; - } - } - - /// <summary> - /// Filter for expected formatting exceptions. Always returns true because - /// ALL exceptions during formatting should result in graceful fallback. - /// This pattern satisfies CodeQL's generic catch clause warning while - /// maintaining the intentional catch-all behavior for formatting operations. - /// </summary> - private static bool IsExpectedFormattingException() => true; - - /// <summary> - /// Request payload for the M-Formatter API. - /// </summary> - private sealed class MFormatterRequest - { - [JsonPropertyName("code")] - public required string Code { get; init; } - - [JsonPropertyName("resultType")] - public required string ResultType { get; init; } - } - - /// <summary> - /// Response payload from the M-Formatter API. - /// </summary> - private sealed class MFormatterResponse - { - [JsonPropertyName("success")] - public bool Success { get; init; } - - [JsonPropertyName("result")] - public string? Result { get; init; } - - [JsonPropertyName("errors")] - public object? Errors { get; init; } - } -} - - diff --git a/src/ExcelMcp.ComInterop/Formatting/NumberFormatTranslator.cs b/src/ExcelMcp.ComInterop/Formatting/NumberFormatTranslator.cs deleted file mode 100644 index 6ae8cda2..00000000 --- a/src/ExcelMcp.ComInterop/Formatting/NumberFormatTranslator.cs +++ /dev/null @@ -1,412 +0,0 @@ -using System.Text; -using Excel = Microsoft.Office.Interop.Excel; - -namespace Sbroenne.ExcelMcp.ComInterop.Formatting; - -/// <summary> -/// Translates number and date/time format codes between US (English) format and the locale-specific format -/// that Excel expects based on the current system locale. -/// </summary> -/// <remarks> -/// <para><b>Why This Is Needed:</b></para> -/// <para> -/// Excel interprets format code characters based on the system locale: -/// </para> -/// <list type="bullet"> -/// <item>Date codes: On German systems, 'd' (day), 'm' (month), 'y' (year) must be 'T', 'M', 'J'</item> -/// <item>Number separators: On German systems, '.' (decimal) and ',' (thousands) are swapped</item> -/// </list> -/// <para> -/// This translator reads the locale-specific codes from Excel's <c>Application.International</c> property -/// and translates US format codes to locale format codes. -/// </para> -/// <para><b>Usage:</b></para> -/// <code> -/// var translator = new NumberFormatTranslator(excelApp); -/// string dateFormat = translator.TranslateToLocale("m/d/yyyy"); // Returns "M/T/JJJJ" on German -/// string currencyFormat = translator.TranslateToLocale("$#,##0.00"); // Returns "$#.##0,00" on German -/// </code> -/// </remarks> -public sealed class NumberFormatTranslator -{ - // XlApplicationInternational enum values for date/time - private const int XlDayCode = 21; - private const int XlMonthCode = 20; - private const int XlYearCode = 19; - private const int XlHourCode = 22; - private const int XlMinuteCode = 23; - private const int XlSecondCode = 24; - private const int XlDateSeparator = 17; - private const int XlTimeSeparator = 18; - - // XlApplicationInternational enum values for number separators - private const int XlDecimalSeparator = 3; - private const int XlThousandsSeparator = 4; - - /// <summary>Locale-specific day code (e.g., 'd' for English, 'T' for German)</summary> - public string DayCode { get; } - - /// <summary>Locale-specific month code (e.g., 'm' for English, 'M' for German)</summary> - public string MonthCode { get; } - - /// <summary>Locale-specific year code (e.g., 'y' for English, 'J' for German)</summary> - public string YearCode { get; } - - /// <summary>Locale-specific hour code (typically 'h' across locales)</summary> - public string HourCode { get; } - - /// <summary>Locale-specific minute code (typically 'm' across locales - same as month!)</summary> - public string MinuteCode { get; } - - /// <summary>Locale-specific second code (typically 's' across locales)</summary> - public string SecondCode { get; } - - /// <summary>Locale-specific date separator (e.g., '/' or '.')</summary> - public string DateSeparator { get; } - - /// <summary>Locale-specific time separator (typically ':')</summary> - public string TimeSeparator { get; } - - /// <summary>Locale-specific decimal separator (e.g., '.' for English, ',' for German)</summary> - public string DecimalSeparator { get; } - - /// <summary>Locale-specific thousands separator (e.g., ',' for English, '.' for German)</summary> - public string ThousandsSeparator { get; } - - /// <summary>True if locale uses same codes as US English (d/m/y)</summary> - public bool IsEnglishDateLocale { get; } - - /// <summary>True if locale uses same number separators as US English (. for decimal, , for thousands)</summary> - public bool IsEnglishNumberLocale { get; } - - /// <summary> - /// Creates a new NumberFormatTranslator by reading locale codes from the Excel Application. - /// </summary> - /// <param name="excelApp">The Excel.Application COM object</param> - public NumberFormatTranslator(Excel.Application excelApp) - { - // Read locale-specific codes from Excel's International property - DayCode = GetInternationalValue(excelApp, XlDayCode) ?? "d"; - MonthCode = GetInternationalValue(excelApp, XlMonthCode) ?? "m"; - YearCode = GetInternationalValue(excelApp, XlYearCode) ?? "y"; - HourCode = GetInternationalValue(excelApp, XlHourCode) ?? "h"; - MinuteCode = GetInternationalValue(excelApp, XlMinuteCode) ?? "m"; - SecondCode = GetInternationalValue(excelApp, XlSecondCode) ?? "s"; - DateSeparator = GetInternationalValue(excelApp, XlDateSeparator) ?? "/"; - TimeSeparator = GetInternationalValue(excelApp, XlTimeSeparator) ?? ":"; - - // Read number separators - DecimalSeparator = GetInternationalValue(excelApp, XlDecimalSeparator) ?? "."; - ThousandsSeparator = GetInternationalValue(excelApp, XlThousandsSeparator) ?? ","; - - // Check if this is already English locale for dates (no translation needed) - IsEnglishDateLocale = DayCode.Equals("d", StringComparison.OrdinalIgnoreCase) && - MonthCode.Equals("m", StringComparison.OrdinalIgnoreCase) && - YearCode.Equals("y", StringComparison.OrdinalIgnoreCase); - - // Check if this is already English locale for numbers (no translation needed) - IsEnglishNumberLocale = DecimalSeparator == "." && ThousandsSeparator == ","; - } - - /// <summary> - /// Translates a US (English) format string to the locale-specific format Excel expects. - /// Handles both date/time codes and number separators. - /// </summary> - /// <param name="usFormat">US format string (e.g., "m/d/yyyy", "$#,##0.00")</param> - /// <returns>Locale-specific format string (e.g., "M/T/JJJJ", "$#.##0,00" on German Excel)</returns> - /// <remarks> - /// <para>Translation rules:</para> - /// <list type="bullet"> - /// <item>'d' or 'dd' (day) → locale day code (e.g., 'T' or 'TT' on German)</item> - /// <item>'ddd' or 'dddd' (weekday names) → kept as-is (Excel handles these)</item> - /// <item>'m' or 'mm' (month, when NOT after time separator) → locale month code</item> - /// <item>'mmm' or 'mmmm' (month names) → kept as-is (Excel handles these)</item> - /// <item>'y' or 'yy' or 'yyyy' (year) → locale year code</item> - /// <item>'h', 'm' (after :), 's' (time) → locale time codes</item> - /// <item>'.' (decimal separator in number formats) → locale decimal separator</item> - /// <item>',' (thousands separator in number formats) → locale thousands separator</item> - /// <item>Literal text in quotes or brackets is preserved</item> - /// </list> - /// </remarks> - public string TranslateToLocale(string usFormat) - { - if (string.IsNullOrEmpty(usFormat)) - return usFormat; - - // If already English locale for both dates and numbers, no translation needed - if (IsEnglishDateLocale && IsEnglishNumberLocale) - return usFormat; - - // Don't translate if it already contains locale-specific codes - // (user might have already used German codes) - if (ContainsLocaleSpecificCodes(usFormat)) - return usFormat; - - // Parse and translate the format string - return TranslateFormatString(usFormat); - } - - /// <summary> - /// Checks if the format string already contains locale-specific date codes. - /// </summary> - private bool ContainsLocaleSpecificCodes(string format) - { - // Check for German-style codes (case-insensitive) - // T = Tag (day), J = Jahr (year) are unique to German - // We check for these to avoid double-translation - if (!DayCode.Equals("d", StringComparison.OrdinalIgnoreCase) && - format.Contains(DayCode, StringComparison.OrdinalIgnoreCase)) - return true; - - if (!YearCode.Equals("y", StringComparison.OrdinalIgnoreCase) && - format.Contains(YearCode, StringComparison.OrdinalIgnoreCase)) - return true; - - return false; - } - - /// <summary> - /// Translates format string character by character, handling context (date vs time vs number). - /// </summary> - private string TranslateFormatString(string format) - { - var result = new StringBuilder(format.Length); - int i = 0; - - // Track if we're in a time context (after seeing 'h' or ':') - bool inTimeContext = false; - - while (i < format.Length) - { - char c = format[i]; - - // Skip content in square brackets (locale prefixes, colors, conditions) - if (c == '[') - { - int bracketEnd = format.IndexOf(']', i); - if (bracketEnd > i) - { - result.Append(format.AsSpan(i, bracketEnd - i + 1)); - i = bracketEnd + 1; - continue; - } - } - - // Skip content in quotes (literal text) - if (c == '"') - { - int quoteEnd = format.IndexOf('"', i + 1); - if (quoteEnd > i) - { - result.Append(format.AsSpan(i, quoteEnd - i + 1)); - i = quoteEnd + 1; - continue; - } - } - - // Skip escaped characters (backslash) - if (c == '\\' && i + 1 < format.Length) - { - result.Append(format.AsSpan(i, 2)); - i += 2; - continue; - } - - // Handle decimal separator '.' in number format context - // A '.' is a decimal separator if it's followed by a digit placeholder (0 or #) - if (c == '.' && !IsEnglishNumberLocale) - { - if (i + 1 < format.Length && IsDigitPlaceholder(format[i + 1])) - { - // This is a decimal separator in a number format - translate it - result.Append(DecimalSeparator); - i++; - continue; - } - } - - // Handle thousands separator ',' in number format context - // A ',' is a thousands separator if it's between digit placeholders - if (c == ',' && !IsEnglishNumberLocale) - { - // Check if this is a thousands separator (surrounded by digit placeholders) - bool prevIsDigit = i > 0 && (IsDigitPlaceholder(format[i - 1]) || format[i - 1] == '.'); - bool nextIsDigit = i + 1 < format.Length && (IsDigitPlaceholder(format[i + 1]) || format[i + 1] == '#' || format[i + 1] == '0'); - - if (prevIsDigit && nextIsDigit) - { - // This is a thousands separator in a number format - translate it - result.Append(ThousandsSeparator); - i++; - continue; - } - } - - // Time separator - switch to time context - if (c == ':') - { - inTimeContext = true; - result.Append(c); - i++; - continue; - } - - // Hour code - switch to time context - if (c == 'h' || c == 'H') - { - inTimeContext = true; - int count = CountRepeatingChar(format, i, c); - if (!IsEnglishDateLocale) - { - result.Append(HourCode[0], count); - } - else - { - result.Append(c, count); - } - i += count; - continue; - } - - // Second code - if (c == 's' || c == 'S') - { - int count = CountRepeatingChar(format, i, c); - if (!IsEnglishDateLocale) - { - result.Append(SecondCode[0], count); - } - else - { - result.Append(c, count); - } - i += count; - continue; - } - - // Day code - 'd' or 'D' - if ((c == 'd' || c == 'D') && !IsEnglishDateLocale) - { - int count = CountRepeatingChar(format, i, c); - - // ddd and dddd are weekday names - keep as-is - if (count >= 3) - { - result.Append(c, count); - } - else - { - // d or dd = day number - result.Append(DayCode[0], count); - } - i += count; - continue; - } - - // Month/Minute code - 'm' or 'M' - // This is the tricky one - 'm' means month in date context, minutes in time context - if ((c == 'm' || c == 'M') && !IsEnglishDateLocale) - { - int count = CountRepeatingChar(format, i, c); - - if (inTimeContext) - { - // In time context, m = minutes - result.Append(MinuteCode[0], count); - } - else - { - // In date context, m = month - // mmm and mmmm are month names - keep as-is (Excel handles translation) - if (count >= 3) - { - result.Append(c, count); - } - else - { - // m or mm = month number - result.Append(MonthCode[0], count); - } - } - i += count; - continue; - } - - // Year code - 'y' or 'Y' - if ((c == 'y' || c == 'Y') && !IsEnglishDateLocale) - { - int count = CountRepeatingChar(format, i, c); - result.Append(YearCode[0], count); - i += count; - continue; - } - - // Other characters pass through unchanged - result.Append(c); - i++; - - // Reset time context on section separator - if (c == ';') - { - inTimeContext = false; - } - } - - return result.ToString(); - } - - /// <summary> - /// Checks if a character is a digit placeholder in Excel number formats. - /// </summary> - private static bool IsDigitPlaceholder(char c) => c == '0' || c == '#' || c == '?'; - - /// <summary> - /// Counts how many times a character repeats starting at position. - /// </summary> - private static int CountRepeatingChar(string format, int startIndex, char c) - { - int count = 0; - char lowerC = char.ToLowerInvariant(c); - - while (startIndex + count < format.Length && - char.ToLowerInvariant(format[startIndex + count]) == lowerC) - { - count++; - } - - return count; - } - - /// <summary> - /// Gets a value from Excel's International property. - /// </summary> - private static string? GetInternationalValue(Excel.Application excelApp, int index) - { - try - { - // Access the International property with the index - // Excel COM: excelApp.International(index) returns the locale-specific value - object? value = excelApp.International[(Excel.XlApplicationInternational)index]; - return value?.ToString(); - } - catch (Exception ex) when (ex is System.Runtime.InteropServices.COMException) - { - // International property access failed for this index - return null; - } - } - - /// <summary> - /// Returns a summary of the locale codes for debugging/logging. - /// </summary> - public override string ToString() - { - return $"NumberFormatTranslator: Day='{DayCode}' Month='{MonthCode}' Year='{YearCode}' " + - $"Hour='{HourCode}' Minute='{MinuteCode}' Second='{SecondCode}' " + - $"DateSep='{DateSeparator}' TimeSep='{TimeSeparator}' " + - $"DecimalSep='{DecimalSeparator}' ThousandsSep='{ThousandsSeparator}' " + - $"IsEnglishDate={IsEnglishDateLocale} IsEnglishNumber={IsEnglishNumberLocale}"; - } -} - - diff --git a/src/ExcelMcp.ComInterop/README.md b/src/ExcelMcp.ComInterop/README.md deleted file mode 100644 index 014fa29a..00000000 --- a/src/ExcelMcp.ComInterop/README.md +++ /dev/null @@ -1,62 +0,0 @@ -# ExcelMcp.ComInterop - -**Low-level COM interop utilities for Excel automation by Sbroenne.** - -## Overview - -This library provides Excel-specific COM object lifecycle management and OLE message filtering. It's the foundation layer for ExcelMcp projects, handling STA threading, session management, and batch operations specifically for Excel COM automation. - -**Note:** Despite the generic name "ComInterop", this library is Excel-specific and not intended for Word/PowerPoint/other Office applications. - -## Features - -- **STA Threading Management** - Ensures proper single-threaded apartment model for Excel COM objects -- **COM Object Lifecycle** - Automatic COM object cleanup and garbage collection -- **OLE Message Filtering** - Handles busy/rejected COM calls with retry logic using Polly -- **Excel Session Management** - Manages Excel.Application lifecycle safely -- **Batch Operations** - Efficient handling of multiple Excel operations in a single session - -## Usage Example - -```csharp -using Sbroenne.ExcelMcp.ComInterop; - -// Use ExcelSession for safe Excel automation -await using var session = await ExcelSession.BeginAsync("path/to/workbook.xlsx"); -await using var batch = await session.BeginBatchAsync(); - -await batch.ExecuteAsync<int>(async (ctx, ct) => -{ - // Access Excel workbook through ctx.Book - dynamic worksheets = ctx.Book.Worksheets; - dynamic sheet = worksheets.Item[1]; - - // Perform Excel operations - sheet.Name = "UpdatedSheet"; - - return 0; -}); - -await batch.Save(); -``` - -## Key Classes - -- **ExcelSession** - Manages Excel.Application lifecycle and workbook operations -- **ExcelBatch** - Groups multiple operations for efficient execution -- **ComUtilities** - Helper methods for COM object cleanup and safe property access -- **OleMessageFilter** - Implements retry logic for busy Excel instances - -## Requirements - -- Windows OS -- .NET 10.0 or later -- Microsoft Excel 2016+ installed - -## Platform Support - -- ✅ Windows x64 -- ✅ Windows ARM64 -- ❌ Linux (Excel COM not available) -- ❌ macOS (Excel COM not available) - diff --git a/src/ExcelMcp.ComInterop/Session/ExcelContext.cs b/src/ExcelMcp.ComInterop/Session/ExcelContext.cs deleted file mode 100644 index 3448242f..00000000 --- a/src/ExcelMcp.ComInterop/Session/ExcelContext.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Formatting; -using Excel = Microsoft.Office.Interop.Excel; - -namespace Sbroenne.ExcelMcp.ComInterop.Session; - -/// <summary> -/// Provides access to Excel COM objects for operations. -/// Simplifies passing Excel application and workbook to operations. -/// </summary> -public sealed class ExcelContext -{ - /// <summary> - /// Creates a new ExcelContext. - /// </summary> - /// <param name="workbookPath">Full path to the workbook</param> - /// <param name="excel">Excel.Application COM object</param> - /// <param name="workbook">Excel.Workbook COM object</param> - public ExcelContext(string workbookPath, Excel.Application excel, Excel.Workbook workbook) - { - WorkbookPath = workbookPath ?? throw new ArgumentNullException(nameof(workbookPath)); - App = excel ?? throw new ArgumentNullException(nameof(excel)); - Book = workbook ?? throw new ArgumentNullException(nameof(workbook)); - - // Initialize number format translator with locale-specific codes from Excel - FormatTranslator = new NumberFormatTranslator(excel); - } - - /// <summary> - /// Gets the full path to the workbook. - /// </summary> - public string WorkbookPath { get; } - - /// <summary> - /// Gets the Excel.Application COM object. - /// </summary> - public Excel.Application App { get; } - - /// <summary> - /// Gets the Excel.Workbook COM object. - /// </summary> - public Excel.Workbook Book { get; } - - /// <summary> - /// Gets the number format translator for converting US format codes to locale-specific codes. - /// </summary> - /// <remarks> - /// <para> - /// Use this to translate format strings like "m/d/yyyy" or "$#,##0.00" to locale-specific codes - /// (e.g., "M/T/JJJJ" and "$#.##0,00" on German Excel) before setting <c>Range.NumberFormat</c>. - /// </para> - /// <example> - /// <code> - /// string localeFormat = ctx.FormatTranslator.TranslateToLocale("m/d/yyyy"); - /// range.NumberFormat = localeFormat; - /// </code> - /// </example> - /// </remarks> - public NumberFormatTranslator FormatTranslator { get; } -} - - diff --git a/src/ExcelMcp.ComInterop/Session/ExcelSession.cs b/src/ExcelMcp.ComInterop/Session/ExcelSession.cs deleted file mode 100644 index f6148e08..00000000 --- a/src/ExcelMcp.ComInterop/Session/ExcelSession.cs +++ /dev/null @@ -1,244 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Runtime.InteropServices; -using Excel = Microsoft.Office.Interop.Excel; - -namespace Sbroenne.ExcelMcp.ComInterop.Session; - -/// <summary> -/// Main entry point for Excel COM interop operations using batch pattern. -/// All operations execute on dedicated STA threads with proper COM cleanup. -/// </summary> -public static class ExcelSession -{ - /// <summary> - /// Global lock to serialize file creation operations. - /// Prevents resource exhaustion from parallel CreateNew() calls. - /// Each CreateNew() spawns a temporary Excel instance - must be sequential. - /// </summary> - private static readonly SemaphoreSlim _createFileLock = new(1, 1); - - /// <summary> - /// Begins a batch of Excel operations against one or more workbook instances. - /// The Excel instance remains open until the batch is disposed, enabling multiple operations - /// without incurring Excel startup/shutdown overhead. - /// </summary> - /// <param name="filePaths">Paths to Excel files. First file is the primary workbook.</param> - /// <returns>IExcelBatch for executing multiple operations</returns> - /// <remarks> - /// All CLI and MCP operations use this batch-based approach for optimal performance. - /// For cross-workbook operations (copy, move), pass multiple file paths. - /// - /// <para><b>Example:</b></para> - /// <code> - /// using var batch = ExcelSession.BeginBatch(filePath); - /// - /// // Synchronous COM operations - /// batch.Execute((ctx, ct) => { - /// ctx.Book.Worksheets.Add("Sales"); - /// return 0; - /// }); - /// - /// batch.Execute((ctx, ct) => { - /// ctx.Book.Worksheets.Add("Expenses"); - /// return 0; - /// }); - /// - /// // Explicit save - /// batch.Save(); - /// - /// // Dispose closes workbook and quits Excel - /// </code> - /// </remarks> - [SuppressMessage("Interoperability", "CA1416:Validate platform compatibility")] - public static IExcelBatch BeginBatch(params string[] filePaths) - => BeginBatch(show: false, operationTimeout: null, filePaths); - - /// <summary> - /// Begins a batch of Excel operations against one or more workbook instances with optional UI visibility. - /// The Excel instance remains open until the batch is disposed, enabling multiple operations - /// without incurring Excel startup/shutdown overhead. - /// </summary> - /// <param name="show">Whether to show the Excel window (default: false for background automation).</param> - /// <param name="operationTimeout">Maximum time for any single operation (default: 5 minutes).</param> - /// <param name="filePaths">Paths to Excel files. First file is the primary workbook.</param> - /// <returns>IExcelBatch for executing multiple operations</returns> - [SuppressMessage("Interoperability", "CA1416:Validate platform compatibility")] - public static IExcelBatch BeginBatch( - bool show, - TimeSpan? operationTimeout, - params string[] filePaths) - { - if (filePaths == null || filePaths.Length == 0) - throw new ArgumentException("At least one file path is required", nameof(filePaths)); - - string[] fullPaths = new string[filePaths.Length]; - for (int i = 0; i < filePaths.Length; i++) - { - string fullPath = Path.GetFullPath(filePaths[i]); - - // Validate file exists - if (!File.Exists(fullPath)) - { - throw new FileNotFoundException($"Excel file not found: {fullPath}. To create a new file, use the 'create' action instead of 'open'.", fullPath); - } - - // Security: Validate file extension - string extension = Path.GetExtension(fullPath).ToLowerInvariant(); - if (extension is not (".xlsx" or ".xlsm" or ".xls")) - { - throw new ArgumentException($"Invalid file extension '{extension}'. Only Excel files (.xlsx, .xlsm, .xls) are supported."); - } - - fullPaths[i] = fullPath; - } - - // Create batch - it will create Excel/workbook on its own STA thread - return new ExcelBatch(fullPaths, logger: null, show: show, operationTimeout: operationTimeout); - } - - /// <summary> - /// Creates a new Excel workbook at the specified path with a synchronous COM operation. - /// Creates a minimal workbook then allows executing an operation before saving. - /// </summary> - /// <typeparam name="T">Return type of the operation</typeparam> - /// <param name="filePath">Path where to save the new Excel file</param> - /// <param name="isMacroEnabled">Whether to create a macro-enabled workbook (.xlsm)</param> - /// <param name="operation">Synchronous COM operation to execute with ExcelContext</param> - /// <param name="cancellationToken">Cancellation token</param> - /// <returns>Result of the operation</returns> - /// <remarks> - /// <para><b>File creation is automatically serialized</b> to prevent resource exhaustion.</para> - /// <para>Even if called in parallel (e.g., Task.WhenAll), calls are queued and executed one at a time.</para> - /// <para>This prevents spawning multiple temporary Excel.Application processes simultaneously.</para> - /// </remarks> - [SuppressMessage("Interoperability", "CA1416:Validate platform compatibility")] - public static T CreateNew<T>( - string filePath, - bool isMacroEnabled, - Func<ExcelContext, CancellationToken, T> operation, - CancellationToken cancellationToken = default) - { - // CRITICAL: Acquire lock to serialize file creation operations - // This prevents parallel CreateNew() calls from spawning multiple Excel processes - // Use timeout to prevent infinite waits if previous operation hung - if (!_createFileLock.Wait(TimeSpan.FromMinutes(2), cancellationToken)) - { - throw new TimeoutException("Timed out waiting for file creation lock. Another CreateNew operation may be stuck."); - } - try - { - string fullPath = Path.GetFullPath(filePath); - - // Validate path length BEFORE attempting Excel operations - // Excel's SaveAs has a practical limit of ~218 characters - if (fullPath.Length > 218) - { - throw new PathTooLongException( - $"File path exceeds Excel's maximum length (~218 characters): {fullPath.Length} characters"); - } - - string? directory = Path.GetDirectoryName(fullPath); - if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) - { - Directory.CreateDirectory(directory); - } - - CreateWorkbookOnStaThread(fullPath, isMacroEnabled, cancellationToken); - - // Now use batch API to execute the operation - using var batch = BeginBatch(fullPath); - var result = batch.Execute(operation, cancellationToken); - // Note: Caller is responsible for saving if needed - - return result; - } - finally - { - // Release lock to allow next CreateNew() call - _createFileLock.Release(); - } - } - - private static void CreateWorkbookOnStaThread(string fullPath, bool isMacroEnabled, CancellationToken cancellationToken) - { - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - var thread = new Thread(() => - { - Excel.Application? excel = null; - Excel.Workbook? workbook = null; - - try - { - OleMessageFilter.Register(); - - var excelType = Type.GetTypeFromProgID("Excel.Application"); - if (excelType == null) - { - throw new InvalidOperationException("Excel is not installed or not properly registered."); - } - -#pragma warning disable IL2072 - excel = (Excel.Application)Activator.CreateInstance(excelType)!; -#pragma warning restore IL2072 - - excel.Visible = false; - excel.DisplayAlerts = false; - - workbook = (Excel.Workbook)excel.Workbooks.Add(); - - // SaveAs directly on STA thread - if (isMacroEnabled) - { - workbook.SaveAs(fullPath, ComInteropConstants.XlOpenXmlWorkbookMacroEnabled); - } - else - { - workbook.SaveAs(fullPath, ComInteropConstants.XlOpenXmlWorkbook); - } - - completion.SetResult(); - } - catch (Exception ex) - { - completion.TrySetException(ex); - } - finally - { - // Simple cleanup - no fancy retry logic needed for a new empty file - try - { - workbook?.Close(false); // Don't save again - } - catch { } - - ComUtilities.TryQuitExcel(excel); - - if (workbook != null) { Marshal.ReleaseComObject(workbook); workbook = null; } - if (excel != null) { Marshal.ReleaseComObject(excel); excel = null; } - - OleMessageFilter.Revoke(); - } - }) - { - IsBackground = true, - Name = $"ExcelCreate-{Path.GetFileName(fullPath)}" - }; - - thread.SetApartmentState(ApartmentState.STA); - thread.Start(); - - // Wait for file creation (with reasonable timeout) - if (!completion.Task.Wait(TimeSpan.FromSeconds(30), cancellationToken)) - { - throw new TimeoutException($"File creation timed out for '{Path.GetFileName(fullPath)}'. Excel may be unresponsive."); - } - - // Wait for cleanup to release the file - thread.Join(TimeSpan.FromSeconds(10)); - } -} - - - - diff --git a/src/ExcelMcp.Core/Commands/Calculation/CalculationModeCommands.cs b/src/ExcelMcp.Core/Commands/Calculation/CalculationModeCommands.cs deleted file mode 100644 index 5e3739cb..00000000 --- a/src/ExcelMcp.Core/Commands/Calculation/CalculationModeCommands.cs +++ /dev/null @@ -1,290 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Attributes; -using Sbroenne.ExcelMcp.Core.Models; -using Excel = Microsoft.Office.Interop.Excel; - -namespace Sbroenne.ExcelMcp.Core.Commands.Calculation; - -/// <summary> -/// Calculation mode enumeration matching Excel's XlCalculation values. -/// </summary> -public enum CalculationMode -{ - /// <summary>xlCalculationAutomatic - Recalculates when any value changes (default)</summary> - Automatic = -4105, - - /// <summary>xlCalculationManual - Only recalculates when explicitly requested</summary> - Manual = -4135, - - /// <summary>xlCalculationSemiautomatic - Auto except data tables (recalc-intensive)</summary> - SemiAutomatic = 2, -} - -/// <summary> -/// Calculation scope for targeted recalculation. -/// </summary> -public enum CalculationScope -{ - /// <summary>Workbook scope - Application.Calculate() recalculates all open workbooks</summary> - Workbook, - - /// <summary>Sheet scope - Worksheet.Calculate() recalculates single sheet only</summary> - Sheet, - - /// <summary>Range scope - Range.Calculate() recalculates single range only</summary> - Range, -} - -/// <summary> -/// Result from get-mode action containing current calculation state. -/// </summary> -public class CalculationModeResult : OperationResult -{ - /// <summary>Current calculation mode as string: automatic, manual, semi-automatic</summary> - public string Mode { get; set; } = string.Empty; - - /// <summary>Raw Excel enumeration value (-4105, -4135, 2)</summary> - public int ModeValue { get; set; } - - /// <summary>Calculation state (pending, done, etc.)</summary> - public string CalculationState { get; set; } = string.Empty; - - /// <summary>Raw Excel calculation state value</summary> - public int CalculationStateValue { get; set; } - - /// <summary>Whether recalculation is pending</summary> - public bool IsPending { get; set; } - - /// <summary>Sheet name (for sheet/range scope operations)</summary> - public string? SheetName { get; set; } - - /// <summary>Range address (for range scope operations)</summary> - public string? RangeAddress { get; set; } - - /// <summary>Calculation scope that was executed</summary> - public string Scope { get; set; } = string.Empty; -} - -/// <summary> -/// Control Excel recalculation (automatic vs manual). Set manual mode before bulk writes -/// for faster performance, then recalculate once at the end. -/// </summary> -[ServiceCategory("calculation", "Calculation")] -[McpTool("calculation_mode", Title = "Calculation Mode Control", Destructive = false, Category = "settings", - Description = "Set or get Excel calculation mode and explicitly recalculate formulas. MODES: automatic (recalculates on every change, default), manual (only when explicitly requested), semi-automatic (auto except data tables). WORKFLOW for batch operations: 1. set-mode(manual) 2. Perform data operations 3. calculate(workbook) 4. set-mode(automatic). SCOPES for calculate: workbook (all formulas), sheet (requires sheetName), range (requires sheetName + rangeAddress).")] -public interface ICalculationModeCommands -{ - /// <summary> - /// Gets the current calculation mode and state. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <returns>Current calculation mode (automatic/manual/semi-automatic)</returns> - [ServiceAction("get-mode")] - CalculationModeResult GetMode(IExcelBatch batch); - - /// <summary> - /// Sets the calculation mode (automatic, manual, semi-automatic). - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="mode">Target calculation mode</param> - /// <returns>Operation result with previous and new mode</returns> - [ServiceAction("set-mode")] - OperationResult SetMode(IExcelBatch batch, [FromString("mode")] CalculationMode mode); - - /// <summary> - /// Triggers calculation for the specified scope. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="scope">Scope: Workbook, Sheet, or Range</param> - /// <param name="sheetName">Sheet name (required for Sheet/Range scope)</param> - /// <param name="rangeAddress">Range address (required for Range scope)</param> - /// <returns>Operation result confirming calculation completed</returns> - [ServiceAction("calculate")] - OperationResult Calculate(IExcelBatch batch, [FromString("scope")] CalculationScope scope, string? sheetName = null, string? rangeAddress = null); -} - -/// <summary> -/// Implementation of calculation mode control commands. -/// </summary> -public class CalculationModeCommands : ICalculationModeCommands -{ - /// <summary> - /// Gets the current calculation mode and state. - /// </summary> - public CalculationModeResult GetMode(IExcelBatch batch) - { - return batch.Execute((ctx, ct) => - { - int modeValue = (int)ctx.App.Calculation; - string mode = modeValue switch - { - -4105 => "automatic", // xlCalculationAutomatic - -4135 => "manual", // xlCalculationManual - 2 => "semi-automatic", // xlCalculationSemiautomatic - _ => "unknown" - }; - - // Get calculation state (if available) - string calcState = "unknown"; - try - { - var calcStateValue = (int)ctx.App.CalculationState; - calcState = calcStateValue switch - { - 1 => "pending", // xlCalculating - 2 => "done", // xlDone - 3 => "pending", // xlPending - _ => "unknown" - }; - } - catch (System.Runtime.InteropServices.COMException) - { - calcState = "done"; // Fallback to done if not available - } - - return new CalculationModeResult - { - Success = true, - Mode = mode, - ModeValue = modeValue, - CalculationState = calcState, - IsPending = calcState == "pending", - Message = $"Calculation mode is {mode}" - }; - }); - } - - /// <summary> - /// Sets the calculation mode (automatic, manual, semi-automatic). - /// </summary> - public OperationResult SetMode(IExcelBatch batch, CalculationMode mode) - { - return batch.Execute((ctx, ct) => - { - int newValue = (int)mode; - string newMode = mode switch - { - CalculationMode.Automatic => "automatic", - CalculationMode.Manual => "manual", - CalculationMode.SemiAutomatic => "semi-automatic", - _ => "unknown" - }; - - try - { - ctx.App.Calculation = (Excel.XlCalculation)newValue; - } - catch (Exception ex) - { - return new OperationResult - { - Success = false, - ErrorMessage = $"Failed to set calculation mode to {newMode}: {ex.Message}" - }; - } - - return new OperationResult - { - Success = true, - Message = $"Calculation mode set to {newMode}" - }; - }); - } - - /// <summary> - /// Triggers calculation for the specified scope (workbook, sheet, or range). - /// </summary> - public OperationResult Calculate(IExcelBatch batch, CalculationScope scope, string? sheetName = null, string? rangeAddress = null) - { - // Validate parameters - if (scope == CalculationScope.Sheet && string.IsNullOrWhiteSpace(sheetName)) - { - return new OperationResult - { - Success = false, - ErrorMessage = "sheetName is required for Sheet scope calculation" - }; - } - - if (scope == CalculationScope.Range && (string.IsNullOrWhiteSpace(sheetName) || string.IsNullOrWhiteSpace(rangeAddress))) - { - return new OperationResult - { - Success = false, - ErrorMessage = "Both sheetName and rangeAddress are required for Range scope calculation" - }; - } - - return batch.Execute((ctx, ct) => - { - try - { - switch (scope) - { - case CalculationScope.Workbook: - ctx.App.Calculate(); - return new OperationResult - { - Success = true, - Message = "Calculation complete for all workbooks" - }; - - case CalculationScope.Sheet: - dynamic? worksheet = null; - try - { - worksheet = ctx.Book.Worksheets[sheetName]; - worksheet.Calculate(); - return new OperationResult - { - Success = true, - Message = $"Calculation complete for sheet '{sheetName}'" - }; - } - finally - { - ComUtilities.Release(ref worksheet); - } - - case CalculationScope.Range: - dynamic? ws = null; - dynamic? rng = null; - try - { - ws = ctx.Book.Worksheets[sheetName]; - rng = ws.Range[rangeAddress]; - rng.Calculate(); - return new OperationResult - { - Success = true, - Message = $"Calculation complete for range '{rangeAddress}' on sheet '{sheetName}'" - }; - } - finally - { - ComUtilities.Release(ref rng); - ComUtilities.Release(ref ws); - } - - default: - return new OperationResult - { - Success = false, - ErrorMessage = $"Unknown calculation scope: {scope}" - }; - } - } - catch (Exception ex) - { - return new OperationResult - { - Success = false, - ErrorMessage = $"Calculation failed: {ex.Message}" - }; - } - }); - } -} - - diff --git a/src/ExcelMcp.Core/Commands/Chart/ChartAxisType.cs b/src/ExcelMcp.Core/Commands/Chart/ChartAxisType.cs deleted file mode 100644 index d85bf27a..00000000 --- a/src/ExcelMcp.Core/Commands/Chart/ChartAxisType.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace Sbroenne.ExcelMcp.Core.Commands.Chart; - -/// <summary> -/// Chart axis types for setting axis titles, scales, and gridlines. -/// </summary> -public enum ChartAxisType -{ - /// <summary>Primary horizontal axis (category axis for most charts)</summary> - Primary, - - /// <summary>Secondary horizontal axis</summary> - Secondary, - - /// <summary>Category axis (X-axis)</summary> - Category, - - /// <summary>Value axis (Y-axis)</summary> - Value, - - /// <summary>Secondary category axis (X-axis on secondary axis group)</summary> - CategorySecondary, - - /// <summary>Secondary value axis (Y-axis on secondary axis group)</summary> - ValueSecondary -} - - diff --git a/src/ExcelMcp.Core/Commands/Chart/ChartCommands.Appearance.cs b/src/ExcelMcp.Core/Commands/Chart/ChartCommands.Appearance.cs deleted file mode 100644 index 763e39b8..00000000 --- a/src/ExcelMcp.Core/Commands/Chart/ChartCommands.Appearance.cs +++ /dev/null @@ -1,1248 +0,0 @@ -using System.Runtime.InteropServices; -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.Chart; - -/// <summary> -/// Chart appearance operations - type, title, axes, legend, style. -/// </summary> -public partial class ChartCommands -{ - /// <inheritdoc /> - public OperationResult SetChartType(IExcelBatch batch, string chartName, ChartType chartType) - { - return batch.Execute((ctx, ct) => - { - // Find chart by name - var findResult = FindChart(ctx.Book, chartName); - if (findResult.Chart == null) - { - throw new InvalidOperationException($"Chart '{chartName}' not found in workbook."); - } - - try - { - // Set chart type (works for both Regular and PivotCharts) - findResult.Chart.ChartType = (int)chartType; - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; // Void operation completed - } - finally - { - if (findResult.Shape != null) ComUtilities.Release(ref findResult.Shape!); - if (findResult.Chart != null) ComUtilities.Release(ref findResult.Chart!); - } - }); - } - - /// <inheritdoc /> - public OperationResult SetTitle(IExcelBatch batch, string chartName, string title) - { - return batch.Execute((ctx, ct) => - { - // Find chart by name - var findResult = FindChart(ctx.Book, chartName); - if (findResult.Chart == null) - { - throw new InvalidOperationException($"Chart '{chartName}' not found in workbook."); - } - - try - { - // Set title (empty string hides title) - if (string.IsNullOrEmpty(title)) - { - findResult.Chart.HasTitle = false; - } - else - { - findResult.Chart.HasTitle = true; - findResult.Chart.ChartTitle.Text = title; - } - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; // Void operation completed - } - finally - { - if (findResult.Shape != null) ComUtilities.Release(ref findResult.Shape!); - if (findResult.Chart != null) ComUtilities.Release(ref findResult.Chart!); - } - }); - } - - /// <inheritdoc /> - public OperationResult SetAxisTitle( - IExcelBatch batch, - string chartName, - ChartAxisType axis, - string title) - { - return batch.Execute((ctx, ct) => - { - // Find chart by name - var findResult = FindChart(ctx.Book, chartName); - if (findResult.Chart == null) - { - throw new InvalidOperationException($"Chart '{chartName}' not found in workbook."); - } - - dynamic? axes = null; - dynamic? targetAxis = null; - - try - { - axes = findResult.Chart.Axes; - - // Map axis type to Excel constants - int axisType = axis switch - { - ChartAxisType.Category => 1, // xlCategory - ChartAxisType.Value => 2, // xlValue - ChartAxisType.Primary => 1, // Primary = Category - ChartAxisType.Secondary => 2, // Secondary = Value - _ => 1 - }; - - targetAxis = axes.Item(axisType); - - // Set axis title (empty string hides title) - if (string.IsNullOrEmpty(title)) - { - targetAxis.HasTitle = false; - } - else - { - targetAxis.HasTitle = true; - targetAxis.AxisTitle.Text = title; - } - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; // Void operation completed - } - finally - { - ComUtilities.Release(ref targetAxis!); - ComUtilities.Release(ref axes!); - if (findResult.Shape != null) ComUtilities.Release(ref findResult.Shape!); - if (findResult.Chart != null) ComUtilities.Release(ref findResult.Chart!); - } - }); - } - - /// <inheritdoc /> - public string GetAxisNumberFormat( - IExcelBatch batch, - string chartName, - ChartAxisType axis) - { - return batch.Execute((ctx, ct) => - { - // Find chart by name - var findResult = FindChart(ctx.Book, chartName); - if (findResult.Chart == null) - { - throw new InvalidOperationException($"Chart '{chartName}' not found in workbook."); - } - - dynamic? axes = null; - dynamic? targetAxis = null; - dynamic? tickLabels = null; - - try - { - axes = findResult.Chart.Axes; - - // Map axis type to Excel constants - int axisType = axis switch - { - ChartAxisType.Category => 1, // xlCategory - ChartAxisType.Value => 2, // xlValue - ChartAxisType.Primary => 1, // Primary = Category - ChartAxisType.Secondary => 2, // Secondary = Value - _ => 1 - }; - - targetAxis = axes.Item(axisType); - tickLabels = targetAxis.TickLabels; - - // Get the number format for axis tick labels - return tickLabels.NumberFormat?.ToString() ?? "General"; - } - finally - { - ComUtilities.Release(ref tickLabels!); - ComUtilities.Release(ref targetAxis!); - ComUtilities.Release(ref axes!); - if (findResult.Shape != null) ComUtilities.Release(ref findResult.Shape!); - if (findResult.Chart != null) ComUtilities.Release(ref findResult.Chart!); - } - }); - } - - /// <inheritdoc /> - public OperationResult SetAxisNumberFormat( - IExcelBatch batch, - string chartName, - ChartAxisType axis, - string numberFormat) - { - return batch.Execute((ctx, ct) => - { - // Find chart by name - var findResult = FindChart(ctx.Book, chartName); - if (findResult.Chart == null) - { - throw new InvalidOperationException($"Chart '{chartName}' not found in workbook."); - } - - dynamic? axes = null; - dynamic? targetAxis = null; - dynamic? tickLabels = null; - - try - { - axes = findResult.Chart.Axes; - - // Map axis type to Excel constants - int axisType = axis switch - { - ChartAxisType.Category => 1, // xlCategory - ChartAxisType.Value => 2, // xlValue - ChartAxisType.Primary => 1, // Primary = Category - ChartAxisType.Secondary => 2, // Secondary = Value - _ => 1 - }; - - targetAxis = axes.Item(axisType); - tickLabels = targetAxis.TickLabels; - - // Set the number format for axis tick labels - tickLabels.NumberFormat = numberFormat; - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; // Void operation completed - } - finally - { - ComUtilities.Release(ref tickLabels!); - ComUtilities.Release(ref targetAxis!); - ComUtilities.Release(ref axes!); - if (findResult.Shape != null) ComUtilities.Release(ref findResult.Shape!); - if (findResult.Chart != null) ComUtilities.Release(ref findResult.Chart!); - } - }); - } - - /// <inheritdoc /> - public OperationResult ShowLegend( - IExcelBatch batch, - string chartName, - bool visible, - LegendPosition? legendPosition = null) - { - return batch.Execute((ctx, ct) => - { - // Find chart by name - var findResult = FindChart(ctx.Book, chartName); - if (findResult.Chart == null) - { - throw new InvalidOperationException($"Chart '{chartName}' not found in workbook."); - } - - dynamic? legend = null; - - try - { - // Show/hide legend - findResult.Chart.HasLegend = visible; - - // Set position if provided and legend is visible - if (visible && legendPosition.HasValue) - { - legend = findResult.Chart.Legend; - legend.Position = (int)legendPosition.Value; - } - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; // Void operation completed - } - finally - { - ComUtilities.Release(ref legend!); - if (findResult.Shape != null) ComUtilities.Release(ref findResult.Shape!); - if (findResult.Chart != null) ComUtilities.Release(ref findResult.Chart!); - } - }); - } - - /// <inheritdoc /> - public OperationResult SetStyle(IExcelBatch batch, string chartName, int styleId) - { - return batch.Execute((ctx, ct) => - { - // Find chart by name - var findResult = FindChart(ctx.Book, chartName); - if (findResult.Chart == null) - { - throw new InvalidOperationException($"Chart '{chartName}' not found in workbook."); - } - - try - { - // Validate range (Excel supports styles 1-48) - if (styleId < 1 || styleId > 48) - { - var hint = styleId == 0 ? " (was the 'style_id' parameter included?)" : ""; - throw new ArgumentException($"Chart style ID must be between 1 and 48. Provided: {styleId}{hint}", nameof(styleId)); - } - - // Set chart style - findResult.Chart.ChartStyle = styleId; - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; // Void operation completed - } - finally - { - if (findResult.Shape != null) ComUtilities.Release(ref findResult.Shape!); - if (findResult.Chart != null) ComUtilities.Release(ref findResult.Chart!); - } - }); - } - - /// <inheritdoc /> - public OperationResult SetPlacement(IExcelBatch batch, string chartName, int placement) - { - return batch.Execute((ctx, ct) => - { - // Find chart by name - var findResult = FindChart(ctx.Book, chartName); - if (findResult.Chart == null) - { - throw new InvalidOperationException($"Chart '{chartName}' not found in workbook."); - } - - try - { - // Validate placement value (xlMoveAndSize=1, xlMove=2, xlFreeFloating=3) - if (placement < 1 || placement > 3) - { - throw new ArgumentException( - $"Placement must be 1 (move and size with cells), 2 (move only), or 3 (free floating). Provided: {placement}", - nameof(placement)); - } - - // Set placement on the shape (ChartObject) - findResult.Shape.Placement = placement; - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; // Void operation completed - } - finally - { - if (findResult.Shape != null) ComUtilities.Release(ref findResult.Shape!); - if (findResult.Chart != null) ComUtilities.Release(ref findResult.Chart!); - } - }); - } - - // === DATA LABELS === - - /// <inheritdoc /> - public OperationResult SetDataLabels( - IExcelBatch batch, - string chartName, - bool? showValue = null, - bool? showPercentage = null, - bool? showSeriesName = null, - bool? showCategoryName = null, - bool? showBubbleSize = null, - string? separator = null, - DataLabelPosition? labelPosition = null, - int? seriesIndex = null) - { - return batch.Execute((ctx, ct) => - { - var findResult = FindChart(ctx.Book, chartName); - if (findResult.Chart == null) - { - throw new InvalidOperationException($"Chart '{chartName}' not found in workbook."); - } - - dynamic? seriesCollection = null; - dynamic? series = null; - dynamic? dataLabels = null; - - try - { - seriesCollection = findResult.Chart.SeriesCollection(); - int seriesCount = seriesCollection.Count; - - if (seriesCount == 0) - { - throw new InvalidOperationException($"Chart '{chartName}' has no data series."); - } - - // Treat 0 as "all series" (MCP clients may send 0 when parameter is omitted) - if (seriesIndex == 0) seriesIndex = null; - - // Determine which series to configure - int startIndex = seriesIndex ?? 1; - int endIndex = seriesIndex ?? seriesCount; - - if (seriesIndex.HasValue && (seriesIndex.Value < 1 || seriesIndex.Value > seriesCount)) - { - throw new ArgumentException($"Series index {seriesIndex.Value} is out of range. Chart has {seriesCount} series (1-based)."); - } - - for (int i = startIndex; i <= endIndex; i++) - { - series = seriesCollection.Item(i); - - // First enable data labels if any property is being set - if (showValue == true || showPercentage == true || showSeriesName == true || - showCategoryName == true || showBubbleSize == true) - { - series.HasDataLabels = true; - } - - dataLabels = series.DataLabels; - - // Apply each property if specified - if (showValue.HasValue) - dataLabels.ShowValue = showValue.Value; - - if (showPercentage.HasValue) - { - try - { - dataLabels.ShowPercentage = showPercentage.Value; - } - catch (System.Runtime.InteropServices.COMException ex) - when (ex.HResult == unchecked((int)0x800A03EC)) - { - throw new InvalidOperationException( - $"ShowPercentage is not supported for this chart type. " + - "Use show_percentage only with pie or doughnut chart types.", ex); - } - } - - if (showSeriesName.HasValue) - dataLabels.ShowSeriesName = showSeriesName.Value; - - if (showCategoryName.HasValue) - dataLabels.ShowCategoryName = showCategoryName.Value; - - if (showBubbleSize.HasValue) - dataLabels.ShowBubbleSize = showBubbleSize.Value; - - if (!string.IsNullOrEmpty(separator)) - dataLabels.Separator = separator; - - if (labelPosition.HasValue) - { - try - { - dataLabels.Position = (int)labelPosition.Value; - } - catch (System.Runtime.InteropServices.COMException ex) - { - throw new InvalidOperationException( - $"Label position '{labelPosition.Value}' is not supported for this chart type. " + - "Bar, column, and area charts support InsideEnd, InsideBase, and OutsideEnd. " + - "Line, scatter, and other chart types support Above, Below, Left, Right, and Center.", ex); - } - } - - // Disable data labels entirely if all show properties are false - if (showValue == false && showPercentage == false && showSeriesName == false && - showCategoryName == false && showBubbleSize == false) - { - series.HasDataLabels = false; - } - - ComUtilities.Release(ref dataLabels!); - dataLabels = null; - ComUtilities.Release(ref series!); - series = null; - } - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref dataLabels!); - ComUtilities.Release(ref series!); - ComUtilities.Release(ref seriesCollection!); - if (findResult.Shape != null) ComUtilities.Release(ref findResult.Shape!); - if (findResult.Chart != null) ComUtilities.Release(ref findResult.Chart!); - } - }); - } - - // === AXIS SCALE === - - /// <inheritdoc /> - public AxisScaleResult GetAxisScale( - IExcelBatch batch, - string chartName, - ChartAxisType axis) - { - return batch.Execute((ctx, ct) => - { - var findResult = FindChart(ctx.Book, chartName); - if (findResult.Chart == null) - { - throw new InvalidOperationException($"Chart '{chartName}' not found in workbook."); - } - - dynamic? axes = null; - dynamic? targetAxis = null; - - try - { - axes = findResult.Chart.Axes; - - // Map axis type to Excel constants - var (axisType, axisGroup) = MapAxisType(axis); - targetAxis = axes.Item(axisType, axisGroup); - - var result = new AxisScaleResult - { - Success = true, - ChartName = chartName, - AxisType = axis.ToString() - }; - - // Get scale properties with safe null handling - result.MinimumScaleIsAuto = targetAxis.MinimumScaleIsAuto; - result.MaximumScaleIsAuto = targetAxis.MaximumScaleIsAuto; - result.MajorUnitIsAuto = targetAxis.MajorUnitIsAuto; - result.MinorUnitIsAuto = targetAxis.MinorUnitIsAuto; - - if (!result.MinimumScaleIsAuto) - result.MinimumScale = targetAxis.MinimumScale; - - if (!result.MaximumScaleIsAuto) - result.MaximumScale = targetAxis.MaximumScale; - - if (!result.MajorUnitIsAuto) - result.MajorUnit = targetAxis.MajorUnit; - - if (!result.MinorUnitIsAuto) - result.MinorUnit = targetAxis.MinorUnit; - - return result; - } - finally - { - ComUtilities.Release(ref targetAxis!); - ComUtilities.Release(ref axes!); - if (findResult.Shape != null) ComUtilities.Release(ref findResult.Shape!); - if (findResult.Chart != null) ComUtilities.Release(ref findResult.Chart!); - } - }); - } - - /// <inheritdoc /> - public OperationResult SetAxisScale( - IExcelBatch batch, - string chartName, - ChartAxisType axis, - double? minimumScale = null, - double? maximumScale = null, - double? majorUnit = null, - double? minorUnit = null) - { - return batch.Execute((ctx, ct) => - { - var findResult = FindChart(ctx.Book, chartName); - if (findResult.Chart == null) - { - throw new InvalidOperationException($"Chart '{chartName}' not found in workbook."); - } - - dynamic? axes = null; - dynamic? targetAxis = null; - - try - { - axes = findResult.Chart.Axes; - - // Map axis type to Excel constants - var (axisType, axisGroup) = MapAxisType(axis); - targetAxis = axes.Item(axisType, axisGroup); - - // Set scale properties - // If value is provided, use it; otherwise, set to auto - if (minimumScale.HasValue) - { - targetAxis.MinimumScaleIsAuto = false; - targetAxis.MinimumScale = minimumScale.Value; - } - - if (maximumScale.HasValue) - { - targetAxis.MaximumScaleIsAuto = false; - targetAxis.MaximumScale = maximumScale.Value; - } - - if (majorUnit.HasValue) - { - targetAxis.MajorUnitIsAuto = false; - targetAxis.MajorUnit = majorUnit.Value; - } - - if (minorUnit.HasValue) - { - targetAxis.MinorUnitIsAuto = false; - targetAxis.MinorUnit = minorUnit.Value; - } - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref targetAxis!); - ComUtilities.Release(ref axes!); - if (findResult.Shape != null) ComUtilities.Release(ref findResult.Shape!); - if (findResult.Chart != null) ComUtilities.Release(ref findResult.Chart!); - } - }); - } - - // === GRIDLINES === - - /// <inheritdoc /> - public GridlinesResult GetGridlines(IExcelBatch batch, string chartName) - { - return batch.Execute((ctx, ct) => - { - var findResult = FindChart(ctx.Book, chartName); - if (findResult.Chart == null) - { - throw new InvalidOperationException($"Chart '{chartName}' not found in workbook."); - } - - dynamic? axes = null; - dynamic? valueAxis = null; - dynamic? categoryAxis = null; - - try - { - axes = findResult.Chart.Axes; - - var result = new GridlinesResult - { - Success = true, - ChartName = chartName, - Gridlines = new GridlinesInfo() - }; - - // Get value axis (type 2) gridlines - try - { - valueAxis = axes.Item(2); // xlValue - result.Gridlines.HasValueMajorGridlines = valueAxis.HasMajorGridlines; - result.Gridlines.HasValueMinorGridlines = valueAxis.HasMinorGridlines; - } - catch (System.Runtime.InteropServices.COMException) - { - // Value axis may not exist for some chart types - } - - // Get category axis (type 1) gridlines - try - { - categoryAxis = axes.Item(1); // xlCategory - result.Gridlines.HasCategoryMajorGridlines = categoryAxis.HasMajorGridlines; - result.Gridlines.HasCategoryMinorGridlines = categoryAxis.HasMinorGridlines; - } - catch (System.Runtime.InteropServices.COMException) - { - // Category axis may not exist for some chart types - } - - return result; - } - finally - { - ComUtilities.Release(ref categoryAxis!); - ComUtilities.Release(ref valueAxis!); - ComUtilities.Release(ref axes!); - if (findResult.Shape != null) ComUtilities.Release(ref findResult.Shape!); - if (findResult.Chart != null) ComUtilities.Release(ref findResult.Chart!); - } - }); - } - - /// <inheritdoc /> - public OperationResult SetGridlines( - IExcelBatch batch, - string chartName, - ChartAxisType axis, - bool? showMajor = null, - bool? showMinor = null) - { - return batch.Execute((ctx, ct) => - { - var findResult = FindChart(ctx.Book, chartName); - if (findResult.Chart == null) - { - throw new InvalidOperationException($"Chart '{chartName}' not found in workbook."); - } - - dynamic? axes = null; - dynamic? targetAxis = null; - - try - { - axes = findResult.Chart.Axes; - - // Map axis type to Excel constants - var (axisType, axisGroup) = MapAxisType(axis); - targetAxis = axes.Item(axisType, axisGroup); - - if (showMajor.HasValue) - targetAxis.HasMajorGridlines = showMajor.Value; - - if (showMinor.HasValue) - targetAxis.HasMinorGridlines = showMinor.Value; - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref targetAxis!); - ComUtilities.Release(ref axes!); - if (findResult.Shape != null) ComUtilities.Release(ref findResult.Shape!); - if (findResult.Chart != null) ComUtilities.Release(ref findResult.Chart!); - } - }); - } - - // === SERIES FORMATTING === - - /// <inheritdoc /> - public OperationResult SetSeriesFormat( - IExcelBatch batch, - string chartName, - int seriesIndex, - MarkerStyle? markerStyle = null, - int? markerSize = null, - string? markerBackgroundColor = null, - string? markerForegroundColor = null, - bool? invertIfNegative = null) - { - return batch.Execute((ctx, ct) => - { - var findResult = FindChart(ctx.Book, chartName); - if (findResult.Chart == null) - { - throw new InvalidOperationException($"Chart '{chartName}' not found in workbook."); - } - - dynamic? seriesCollection = null; - dynamic? series = null; - - try - { - seriesCollection = findResult.Chart.SeriesCollection(); - int seriesCount = seriesCollection.Count; - - if (seriesIndex < 1 || seriesIndex > seriesCount) - { - throw new ArgumentException($"Series index {seriesIndex} is out of range. Chart has {seriesCount} series (1-based indexing, use 1 for first series)."); - } - - series = seriesCollection.Item(seriesIndex); - - // Set marker style - if (markerStyle.HasValue) - series.MarkerStyle = (int)markerStyle.Value; - - // Set marker size (valid range: 2-72) - if (markerSize.HasValue) - { - if (markerSize.Value < 2 || markerSize.Value > 72) - { - throw new ArgumentException($"Marker size must be between 2 and 72. Provided: {markerSize.Value}"); - } - series.MarkerSize = markerSize.Value; - } - - // Set marker background color (fill) - if (!string.IsNullOrEmpty(markerBackgroundColor)) - { - int bgColor = ParseHexColor(markerBackgroundColor); - series.MarkerBackgroundColor = bgColor; - } - - // Set marker foreground color (border) - if (!string.IsNullOrEmpty(markerForegroundColor)) - { - int fgColor = ParseHexColor(markerForegroundColor); - series.MarkerForegroundColor = fgColor; - } - - // Set invert if negative - if (invertIfNegative.HasValue) - series.InvertIfNegative = invertIfNegative.Value; - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref series!); - ComUtilities.Release(ref seriesCollection!); - if (findResult.Shape != null) ComUtilities.Release(ref findResult.Shape!); - if (findResult.Chart != null) ComUtilities.Release(ref findResult.Chart!); - } - }); - } - - // === HELPER METHODS === - - /// <summary> - /// Maps ChartAxisType to Excel axis type and axis group constants. - /// </summary> - private static (int axisType, int axisGroup) MapAxisType(ChartAxisType axis) - { - return axis switch - { - ChartAxisType.Category => (1, 1), // xlCategory, xlPrimary - ChartAxisType.Value => (2, 1), // xlValue, xlPrimary - ChartAxisType.Primary => (1, 1), // xlCategory, xlPrimary - ChartAxisType.Secondary => (2, 1), // xlValue, xlPrimary - ChartAxisType.CategorySecondary => (1, 2), // xlCategory, xlSecondary - ChartAxisType.ValueSecondary => (2, 2), // xlValue, xlSecondary - _ => (1, 1) - }; - } - - /// <summary> - /// Parses a hex color string (#RRGGBB) to an Excel color integer (BGR format). - /// </summary> - private static int ParseHexColor(string hexColor) - { - // Remove # prefix if present - string colorValue = hexColor.TrimStart('#'); - - if (colorValue.Length != 6) - { - throw new ArgumentException($"Invalid hex color format: {hexColor}. Use #RRGGBB format."); - } - - // Parse RGB components - int r = Convert.ToInt32(colorValue[..2], 16); - int g = Convert.ToInt32(colorValue.Substring(2, 2), 16); - int b = Convert.ToInt32(colorValue.Substring(4, 2), 16); - - // Excel uses BGR format (Blue * 65536 + Green * 256 + Red) - return b * 65536 + g * 256 + r; - } - - // === TRENDLINE OPERATIONS === - - /// <inheritdoc /> - public TrendlineListResult ListTrendlines(IExcelBatch batch, string chartName, int seriesIndex) - { - return batch.Execute((ctx, ct) => - { - var findResult = FindChart(ctx.Book, chartName); - if (findResult.Chart == null) - { - throw new InvalidOperationException($"Chart '{chartName}' not found in workbook."); - } - - dynamic? seriesCollection = null; - dynamic? series = null; - dynamic? trendlines = null; - - try - { - seriesCollection = findResult.Chart.SeriesCollection(); - int seriesCount = seriesCollection.Count; - - if (seriesIndex < 1 || seriesIndex > seriesCount) - { - throw new ArgumentException($"Series index {seriesIndex} is out of range. Chart has {seriesCount} series (1-based indexing, use 1 for first series)."); - } - - series = seriesCollection.Item(seriesIndex); - trendlines = series.Trendlines(); - - var result = new TrendlineListResult - { - Success = true, - ChartName = chartName, - SeriesIndex = seriesIndex, - SeriesName = series.Name?.ToString() ?? $"Series {seriesIndex}" - }; - - int trendlineCount = trendlines.Count; - for (int i = 1; i <= trendlineCount; i++) - { - dynamic? trendline = null; - try - { - trendline = trendlines.Item(i); - var info = new TrendlineInfo - { - Index = i, - Type = (TrendlineType)Convert.ToInt32(trendline.Type), - Name = trendline.Name?.ToString(), - DisplayEquation = trendline.DisplayEquation, - DisplayRSquared = trendline.DisplayRSquared - }; - - // Get forward/backward forecast periods - try { info.Forward = trendline.Forward; } catch (COMException) { /* Optional COM property */ } - try { info.Backward = trendline.Backward; } catch (COMException) { /* Optional COM property */ } - try { info.Intercept = trendline.Intercept; } catch (COMException) { /* Optional COM property */ } - - // Get order for polynomial trendlines - if (info.Type == TrendlineType.Polynomial) - { - try { info.Order = Convert.ToInt32(trendline.Order); } catch (COMException) { /* Optional COM property */ } - } - - // Get period for moving average - if (info.Type == TrendlineType.MovingAverage) - { - try { info.Period = Convert.ToInt32(trendline.Period); } catch (COMException) { /* Optional COM property */ } - } - - result.Trendlines.Add(info); - } - finally - { - ComUtilities.Release(ref trendline!); - } - } - - return result; - } - finally - { - ComUtilities.Release(ref trendlines!); - ComUtilities.Release(ref series!); - ComUtilities.Release(ref seriesCollection!); - if (findResult.Shape != null) ComUtilities.Release(ref findResult.Shape!); - if (findResult.Chart != null) ComUtilities.Release(ref findResult.Chart!); - } - }); - } - - /// <inheritdoc /> - public TrendlineResult AddTrendline( - IExcelBatch batch, - string chartName, - int seriesIndex, - TrendlineType trendlineType, - int? order = null, - int? period = null, - double? forward = null, - double? backward = null, - double? intercept = null, - bool displayEquation = false, - bool displayRSquared = false, - string? name = null) - { - // Validate type-specific parameters - if (trendlineType == TrendlineType.Polynomial && (!order.HasValue || order.Value < 2 || order.Value > 6)) - { - throw new ArgumentException("Polynomial trendline requires order parameter (2-6)."); - } - - if (trendlineType == TrendlineType.MovingAverage && (!period.HasValue || period.Value < 2)) - { - throw new ArgumentException("Moving average trendline requires period parameter (2 or greater)."); - } - - return batch.Execute((ctx, ct) => - { - var findResult = FindChart(ctx.Book, chartName); - if (findResult.Chart == null) - { - throw new InvalidOperationException($"Chart '{chartName}' not found in workbook."); - } - - dynamic? seriesCollection = null; - dynamic? series = null; - dynamic? trendlines = null; - dynamic? newTrendline = null; - - try - { - seriesCollection = findResult.Chart.SeriesCollection(); - int seriesCount = seriesCollection.Count; - - if (seriesIndex < 1 || seriesIndex > seriesCount) - { - throw new ArgumentException($"Series index {seriesIndex} is out of range. Chart has {seriesCount} series (1-based indexing, use 1 for first series)."); - } - - series = seriesCollection.Item(seriesIndex); - trendlines = series.Trendlines(); - - // Add trendline with type - newTrendline = trendlines.Add((int)trendlineType); - - // Set optional parameters - if (order.HasValue && trendlineType == TrendlineType.Polynomial) - { - newTrendline.Order = order.Value; - } - - if (period.HasValue && trendlineType == TrendlineType.MovingAverage) - { - newTrendline.Period = period.Value; - } - - if (forward.HasValue) - { - newTrendline.Forward = forward.Value; - } - - if (backward.HasValue) - { - newTrendline.Backward = backward.Value; - } - - if (intercept.HasValue) - { - newTrendline.Intercept = intercept.Value; - } - - newTrendline.DisplayEquation = displayEquation; - newTrendline.DisplayRSquared = displayRSquared; - - if (!string.IsNullOrEmpty(name)) - { - newTrendline.Name = name; - } - - // Get the index of the newly added trendline - int trendlineIndex = trendlines.Count; - - return new TrendlineResult - { - Success = true, - ChartName = chartName, - SeriesIndex = seriesIndex, - TrendlineIndex = trendlineIndex, - Type = trendlineType, - Name = name - }; - } - finally - { - ComUtilities.Release(ref newTrendline!); - ComUtilities.Release(ref trendlines!); - ComUtilities.Release(ref series!); - ComUtilities.Release(ref seriesCollection!); - if (findResult.Shape != null) ComUtilities.Release(ref findResult.Shape!); - if (findResult.Chart != null) ComUtilities.Release(ref findResult.Chart!); - } - }); - } - - /// <inheritdoc /> - public OperationResult DeleteTrendline(IExcelBatch batch, string chartName, int seriesIndex, int trendlineIndex) - { - return batch.Execute((ctx, ct) => - { - var findResult = FindChart(ctx.Book, chartName); - if (findResult.Chart == null) - { - throw new InvalidOperationException($"Chart '{chartName}' not found in workbook."); - } - - dynamic? seriesCollection = null; - dynamic? series = null; - dynamic? trendlines = null; - dynamic? trendline = null; - - try - { - seriesCollection = findResult.Chart.SeriesCollection(); - int seriesCount = seriesCollection.Count; - - if (seriesIndex < 1 || seriesIndex > seriesCount) - { - throw new ArgumentException($"Series index {seriesIndex} is out of range. Chart has {seriesCount} series (1-based indexing, use 1 for first series)."); - } - - series = seriesCollection.Item(seriesIndex); - trendlines = series.Trendlines(); - int trendlineCount = trendlines.Count; - - if (trendlineIndex < 1 || trendlineIndex > trendlineCount) - { - throw new ArgumentException($"Trendline index {trendlineIndex} is out of range. Series has {trendlineCount} trendlines."); - } - - trendline = trendlines.Item(trendlineIndex); - trendline.Delete(); - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref trendline!); - ComUtilities.Release(ref trendlines!); - ComUtilities.Release(ref series!); - ComUtilities.Release(ref seriesCollection!); - if (findResult.Shape != null) ComUtilities.Release(ref findResult.Shape!); - if (findResult.Chart != null) ComUtilities.Release(ref findResult.Chart!); - } - }); - } - - /// <inheritdoc /> - public OperationResult SetTrendline( - IExcelBatch batch, - string chartName, - int seriesIndex, - int trendlineIndex, - double? forward = null, - double? backward = null, - double? intercept = null, - bool? displayEquation = null, - bool? displayRSquared = null, - string? name = null) - { - return batch.Execute((ctx, ct) => - { - var findResult = FindChart(ctx.Book, chartName); - if (findResult.Chart == null) - { - throw new InvalidOperationException($"Chart '{chartName}' not found in workbook."); - } - - dynamic? seriesCollection = null; - dynamic? series = null; - dynamic? trendlines = null; - dynamic? trendline = null; - - try - { - seriesCollection = findResult.Chart.SeriesCollection(); - int seriesCount = seriesCollection.Count; - - if (seriesIndex < 1 || seriesIndex > seriesCount) - { - throw new ArgumentException($"Series index {seriesIndex} is out of range. Chart has {seriesCount} series (1-based indexing, use 1 for first series)."); - } - - series = seriesCollection.Item(seriesIndex); - trendlines = series.Trendlines(); - int trendlineCount = trendlines.Count; - - if (trendlineIndex < 1 || trendlineIndex > trendlineCount) - { - throw new ArgumentException($"Trendline index {trendlineIndex} is out of range. Series has {trendlineCount} trendlines."); - } - - trendline = trendlines.Item(trendlineIndex); - - // Update optional parameters - if (forward.HasValue) - { - trendline.Forward = forward.Value; - } - - if (backward.HasValue) - { - trendline.Backward = backward.Value; - } - - if (intercept.HasValue) - { - trendline.Intercept = intercept.Value; - } - - if (displayEquation.HasValue) - { - trendline.DisplayEquation = displayEquation.Value; - } - - if (displayRSquared.HasValue) - { - trendline.DisplayRSquared = displayRSquared.Value; - } - - if (!string.IsNullOrEmpty(name)) - { - trendline.Name = name; - } - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref trendline!); - ComUtilities.Release(ref trendlines!); - ComUtilities.Release(ref series!); - ComUtilities.Release(ref seriesCollection!); - if (findResult.Shape != null) ComUtilities.Release(ref findResult.Shape!); - if (findResult.Chart != null) ComUtilities.Release(ref findResult.Chart!); - } - }); - } - - /// <inheritdoc /> - public OperationResult FitToRange(IExcelBatch batch, string chartName, string sheetName, string rangeAddress) - { - return batch.Execute((ctx, ct) => - { - // Find chart by name - var findResult = FindChart(ctx.Book, chartName); - if (findResult.Chart == null) - { - throw new InvalidOperationException($"Chart '{chartName}' not found in workbook."); - } - - dynamic? worksheet = null; - dynamic? range = null; - - try - { - // Get the target range - worksheet = ctx.Book.Worksheets[sheetName]; - range = worksheet.Range[rangeAddress]; - - // Get range geometry - double left = Convert.ToDouble(range.Left); - double top = Convert.ToDouble(range.Top); - double width = Convert.ToDouble(range.Width); - double height = Convert.ToDouble(range.Height); - - // Apply to chart shape - findResult.Shape.Left = left; - findResult.Shape.Top = top; - findResult.Shape.Width = width; - findResult.Shape.Height = height; - - // Collision detection after repositioning - var warnings = ChartPositionHelpers.DetectCollisions( - worksheet, left, top, width, height, chartName); - int chartCount = ChartPositionHelpers.CountCharts(worksheet); - - return new OperationResult - { - Success = true, - FilePath = batch.WorkbookPath, - Message = ChartPositionHelpers.FormatCollisionWarnings(warnings, chartCount) - }; - } - finally - { - ComUtilities.Release(ref range!); - ComUtilities.Release(ref worksheet!); - if (findResult.Shape != null) ComUtilities.Release(ref findResult.Shape!); - if (findResult.Chart != null) ComUtilities.Release(ref findResult.Chart!); - } - }); - } -} - - diff --git a/src/ExcelMcp.Core/Commands/Chart/ChartCommands.DataSource.cs b/src/ExcelMcp.Core/Commands/Chart/ChartCommands.DataSource.cs deleted file mode 100644 index 1f576c02..00000000 --- a/src/ExcelMcp.Core/Commands/Chart/ChartCommands.DataSource.cs +++ /dev/null @@ -1,189 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.Chart; - -/// <summary> -/// Result of finding a chart by name. -/// </summary> -internal sealed class ChartFindResult -{ - public dynamic? Chart; - public dynamic? Shape; - public string SheetName = string.Empty; -} - -/// <summary> -/// Chart data source operations - set range, add/remove series. -/// </summary> -public partial class ChartCommands -{ - /// <inheritdoc /> - public OperationResult SetSourceRange(IExcelBatch batch, string chartName, string sourceRange) - { - return batch.Execute((ctx, ct) => - { - // Find chart by name - var findResult = FindChart(ctx.Book, chartName); - if (findResult.Chart == null) - { - throw new InvalidOperationException($"Chart '{chartName}' not found in workbook."); - } - - try - { - // Determine strategy and delegate - IChartStrategy strategy = _pivotStrategy.CanHandle(findResult.Chart) ? _pivotStrategy : _regularStrategy; -#pragma warning disable CS8604 // CodeQL false positive: Both strategies implement IChartStrategy.SetSourceRange with dynamic parameter - strategy.SetSourceRange(findResult.Chart, sourceRange); -#pragma warning restore CS8604 - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; // Void operation completed - } - finally - { - if (findResult.Shape != null) ComUtilities.Release(ref findResult.Shape!); - if (findResult.Chart != null) ComUtilities.Release(ref findResult.Chart!); - } - }); - } - - /// <inheritdoc /> - public SeriesInfo AddSeries( - IExcelBatch batch, - string chartName, - string seriesName, - string valuesRange, - string? categoryRange = null) - { - return batch.Execute((ctx, ct) => - { - // Find chart by name - var findResult = FindChart(ctx.Book, chartName); - if (findResult.Chart == null) - { - throw new InvalidOperationException($"Chart '{chartName}' not found in workbook."); - } - - try - { - // Determine strategy and delegate - IChartStrategy strategy = _pivotStrategy.CanHandle(findResult.Chart) ? _pivotStrategy : _regularStrategy; -#pragma warning disable CS8604 // CodeQL false positive: Both strategies implement IChartStrategy.AddSeries with dynamic parameter - var result = strategy.AddSeries(findResult.Chart, seriesName, valuesRange, categoryRange); -#pragma warning restore CS8604 - - return result; - } - finally - { - if (findResult.Shape != null) ComUtilities.Release(ref findResult.Shape!); - if (findResult.Chart != null) ComUtilities.Release(ref findResult.Chart!); - } - }); - } - - /// <inheritdoc /> - public OperationResult RemoveSeries(IExcelBatch batch, string chartName, int seriesIndex) - { - return batch.Execute((ctx, ct) => - { - // Find chart by name - var findResult = FindChart(ctx.Book, chartName); - if (findResult.Chart == null) - { - throw new InvalidOperationException($"Chart '{chartName}' not found in workbook."); - } - - try - { - // Determine strategy and delegate - IChartStrategy strategy = _pivotStrategy.CanHandle(findResult.Chart) ? _pivotStrategy : _regularStrategy; -#pragma warning disable CS8604 // CodeQL false positive: Both strategies implement IChartStrategy.RemoveSeries with dynamic parameter - strategy.RemoveSeries(findResult.Chart, seriesIndex); -#pragma warning restore CS8604 - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; // Void operation completed - } - finally - { - if (findResult.Shape != null) ComUtilities.Release(ref findResult.Shape!); - if (findResult.Chart != null) ComUtilities.Release(ref findResult.Chart!); - } - }); - } - - /// <summary> - /// Finds a chart by name across all worksheets. - /// Returns result with chart, shape, sheetName properties. Caller must release chart and shape. - /// </summary> - private static ChartFindResult FindChart(dynamic workbook, string chartName) - { - dynamic worksheets = workbook.Worksheets; - int wsCount = Convert.ToInt32(worksheets.Count); - - for (int i = 1; i <= wsCount; i++) - { - dynamic? worksheet = null; - dynamic? shapes = null; - - try - { - worksheet = worksheets.Item(i); - string sheetName = worksheet.Name?.ToString() ?? $"Sheet{i}"; - shapes = worksheet.Shapes; - int shapeCount = Convert.ToInt32(shapes.Count); - - for (int j = 1; j <= shapeCount; j++) - { - dynamic? shape = null; - dynamic? chart = null; - - try - { - shape = shapes.Item(j); - - // Check if this is a chart (msoChart = 3) - if (Convert.ToInt32(shape.Type) != 3) - { - ComUtilities.Release(ref shape!); - continue; - } - - string shapeName = shape.Name?.ToString() ?? string.Empty; - if (!shapeName.Equals(chartName, StringComparison.OrdinalIgnoreCase)) - { - ComUtilities.Release(ref shape!); - continue; - } - - // Found it! - chart = shape.Chart; - ComUtilities.Release(ref shapes!); - ComUtilities.Release(ref worksheet!); - ComUtilities.Release(ref worksheets!); - - return new ChartFindResult { Chart = chart, Shape = shape, SheetName = sheetName }; // Caller must release both - } - catch (System.Runtime.InteropServices.COMException) - { - ComUtilities.Release(ref chart!); - ComUtilities.Release(ref shape!); - throw; - } - } - } - finally - { - ComUtilities.Release(ref shapes!); - ComUtilities.Release(ref worksheet!); - } - } - - ComUtilities.Release(ref worksheets!); - return new ChartFindResult { Chart = null, Shape = null, SheetName = string.Empty }; - } -} - - diff --git a/src/ExcelMcp.Core/Commands/Chart/ChartCommands.Lifecycle.cs b/src/ExcelMcp.Core/Commands/Chart/ChartCommands.Lifecycle.cs deleted file mode 100644 index 00edd0fb..00000000 --- a/src/ExcelMcp.Core/Commands/Chart/ChartCommands.Lifecycle.cs +++ /dev/null @@ -1,706 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.Chart; - -/// <summary> -/// Chart lifecycle operations - list, read, create, delete, move/resize. -/// </summary> -public partial class ChartCommands : IChartCommands, IChartConfigCommands -{ - private readonly RegularChartStrategy _regularStrategy = new(); - private readonly PivotChartStrategy _pivotStrategy = new(); - - /// <inheritdoc /> - public List<ChartInfo> List(IExcelBatch batch) - { - return batch.Execute((ctx, ct) => - { - var charts = new List<ChartInfo>(); - - dynamic worksheets = ctx.Book.Worksheets; - int wsCount = Convert.ToInt32(worksheets.Count); - - for (int i = 1; i <= wsCount; i++) - { - dynamic? worksheet = null; - dynamic? shapes = null; - - try - { - worksheet = worksheets.Item(i); - string sheetName = worksheet.Name?.ToString() ?? $"Sheet{i}"; - shapes = worksheet.Shapes; - int shapeCount = Convert.ToInt32(shapes.Count); - - for (int j = 1; j <= shapeCount; j++) - { - dynamic? shape = null; - dynamic? chart = null; - - try - { - shape = shapes.Item(j); - - // Check if this is a chart (msoChart = 3) - if (Convert.ToInt32(shape.Type) != 3) - { - continue; - } - - chart = shape.Chart; - string chartName = shape.Name?.ToString() ?? $"Chart{j}"; - - // Determine strategy and get info - IChartStrategy strategy = _pivotStrategy.CanHandle(chart) ? _pivotStrategy : _regularStrategy; -#pragma warning disable CS8604 // CodeQL false positive: Both strategies implement IChartStrategy.GetInfo with dynamic parameters - var chartInfo = strategy.GetInfo(chart, chartName, sheetName, shape); -#pragma warning restore CS8604 - - charts.Add(chartInfo); - } - finally - { - ComUtilities.Release(ref chart!); - ComUtilities.Release(ref shape!); - } - } - } - finally - { - ComUtilities.Release(ref shapes!); - ComUtilities.Release(ref worksheet!); - } - } - - ComUtilities.Release(ref worksheets!); - - return charts; - }); - } - - /// <inheritdoc /> - public ChartInfoResult Read(IExcelBatch batch, string chartName) - { - return batch.Execute((ctx, ct) => - { - // Find chart by name across all worksheets - dynamic worksheets = ctx.Book.Worksheets; - int wsCount = Convert.ToInt32(worksheets.Count); - - for (int i = 1; i <= wsCount; i++) - { - dynamic? worksheet = null; - dynamic? shapes = null; - - try - { - worksheet = worksheets.Item(i); - string sheetName = worksheet.Name?.ToString() ?? $"Sheet{i}"; - shapes = worksheet.Shapes; - int shapeCount = Convert.ToInt32(shapes.Count); - - for (int j = 1; j <= shapeCount; j++) - { - dynamic? shape = null; - dynamic? chart = null; - - try - { - shape = shapes.Item(j); - - // Check if this is a chart and name matches - if (Convert.ToInt32(shape.Type) != 3) - { - continue; - } - - string shapeName = shape.Name?.ToString() ?? string.Empty; - if (!shapeName.Equals(chartName, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - chart = shape.Chart; - - // Determine strategy and get detailed info - IChartStrategy strategy = _pivotStrategy.CanHandle(chart) ? _pivotStrategy : _regularStrategy; -#pragma warning disable CS8604 // CodeQL false positive: Both strategies implement IChartStrategy.GetDetailedInfo with dynamic parameters - var result = strategy.GetDetailedInfo(chart, chartName, sheetName, shape); -#pragma warning restore CS8604 - - ComUtilities.Release(ref chart!); - ComUtilities.Release(ref shape!); - ComUtilities.Release(ref shapes!); - ComUtilities.Release(ref worksheet!); - ComUtilities.Release(ref worksheets!); - - return result; - } - catch (System.Runtime.InteropServices.COMException) - { - ComUtilities.Release(ref chart!); - ComUtilities.Release(ref shape!); - throw; - } - } - } - finally - { - ComUtilities.Release(ref shapes!); - ComUtilities.Release(ref worksheet!); - } - } - - ComUtilities.Release(ref worksheets!); - - // Chart not found - throw new InvalidOperationException($"Chart '{chartName}' not found in workbook."); - }); - } - - /// <inheritdoc /> - public ChartCreateResult CreateFromRange( - IExcelBatch batch, - string sheetName, - string sourceRangeAddress, - ChartType chartType, - double left = 0, - double top = 0, - double width = 400, - double height = 300, - string? chartName = null, - string? targetRange = null) - { - return batch.Execute((ctx, ct) => - { - dynamic? worksheet = null; - dynamic? shapes = null; - dynamic? shape = null; - dynamic? chart = null; - dynamic? targetRangeObj = null; - - try - { - worksheet = ctx.Book.Worksheets[sheetName]; - shapes = worksheet.Shapes; - - // Resolve final position: targetRange > explicit left/top > auto-position - double finalLeft = left; - double finalTop = top; - double finalWidth = width; - double finalHeight = height; - - if (!string.IsNullOrWhiteSpace(targetRange)) - { - // targetRange takes precedence — resolve range geometry - targetRangeObj = worksheet.Range[targetRange]; - finalLeft = Convert.ToDouble(targetRangeObj.Left); - finalTop = Convert.ToDouble(targetRangeObj.Top); - finalWidth = Convert.ToDouble(targetRangeObj.Width); - finalHeight = Convert.ToDouble(targetRangeObj.Height); - } - else if (left == 0 && top == 0) - { - // No explicit position — auto-position below content - // Cast explicitly to avoid dynamic dispatch losing named tuple members - (double Left, double Top) autoPos = ChartPositionHelpers.FindAvailablePosition(worksheet, width, height); - finalLeft = autoPos.Left; - finalTop = autoPos.Top; - } - - // Create chart using AddChart - shape = shapes.AddChart( - XlChartType: (int)chartType, - Left: finalLeft, - Top: finalTop, - Width: finalWidth, - Height: finalHeight - ); - - chart = shape.Chart; - - // Set data source - need to get Range object from string address - dynamic? sourceRangeObj = null; - try - { - // Get the range object from the address string - // If sourceRangeAddress doesn't include sheet name, prefix it - // Sheet names with spaces or special characters must be quoted: 'Sheet Name'!A1:D6 - string fullRangeAddress = sourceRangeAddress.Contains('!') - ? sourceRangeAddress - : $"'{sheetName}'!{sourceRangeAddress}"; - sourceRangeObj = ctx.Book.Application.Range[fullRangeAddress]; - try - { - chart.SetSourceData(sourceRangeObj); - } - catch (System.Runtime.InteropServices.COMException ex) - when (ex.HResult == unchecked((int)0x800A03EC)) - { - throw new InvalidOperationException( - $"Cannot set chart data source to '{sourceRangeAddress}'. " + - "The range must be contiguous, non-empty, and accessible. " + - "If the data is not in a table, consider creating a table first with " + - "table(action='create'), then use chart(action='create-from-table').", ex); - } - } - finally - { - if (sourceRangeObj != null) - { - ComUtilities.Release(ref sourceRangeObj!); - } - } - - // Set custom name if provided - if (!string.IsNullOrWhiteSpace(chartName)) - { - shape.Name = chartName; - } - - string finalName = shape.Name?.ToString() ?? "Chart"; - - // Collision detection — warn about overlaps after positioning - var warnings = ChartPositionHelpers.DetectCollisions( - worksheet, finalLeft, finalTop, finalWidth, finalHeight, finalName); - int chartCount = ChartPositionHelpers.CountCharts(worksheet); - - var result = new ChartCreateResult - { - Success = true, - ChartName = finalName, - SheetName = sheetName, - ChartType = chartType, - IsPivotChart = false, - Left = finalLeft, - Top = finalTop, - Width = finalWidth, - Height = finalHeight, - Message = ChartPositionHelpers.FormatCollisionWarnings(warnings, chartCount) - }; - - return result; - } - finally - { - ComUtilities.Release(ref targetRangeObj!); - ComUtilities.Release(ref chart!); - ComUtilities.Release(ref shape!); - ComUtilities.Release(ref shapes!); - ComUtilities.Release(ref worksheet!); - } - }); - } - - /// <inheritdoc /> - public ChartCreateResult CreateFromTable( - IExcelBatch batch, - string tableName, - string sheetName, - ChartType chartType, - double left = 0, - double top = 0, - double width = 400, - double height = 300, - string? chartName = null, - string? targetRange = null) - { - return batch.Execute((ctx, ct) => - { - dynamic? table = null; - dynamic? tableRange = null; - dynamic? worksheet = null; - dynamic? shapes = null; - dynamic? shape = null; - dynamic? chart = null; - dynamic? targetRangeObj = null; - - try - { - // Find the table using CoreLookupHelpers - table = CoreLookupHelpers.FindTable(ctx.Book, tableName); - - // Get the table's data range (includes headers) - tableRange = table.Range; - - // Get target worksheet - worksheet = ctx.Book.Worksheets[sheetName]; - shapes = worksheet.Shapes; - - // Resolve final position: targetRange > explicit left/top > auto-position - double finalLeft = left; - double finalTop = top; - double finalWidth = width; - double finalHeight = height; - - if (!string.IsNullOrWhiteSpace(targetRange)) - { - targetRangeObj = worksheet.Range[targetRange]; - finalLeft = Convert.ToDouble(targetRangeObj.Left); - finalTop = Convert.ToDouble(targetRangeObj.Top); - finalWidth = Convert.ToDouble(targetRangeObj.Width); - finalHeight = Convert.ToDouble(targetRangeObj.Height); - } - else if (left == 0 && top == 0) - { - // Cast explicitly to avoid dynamic dispatch losing named tuple members - (double Left, double Top) autoPos = ChartPositionHelpers.FindAvailablePosition(worksheet, width, height); - finalLeft = autoPos.Left; - finalTop = autoPos.Top; - } - - // Create chart using AddChart - shape = shapes.AddChart( - XlChartType: (int)chartType, - Left: finalLeft, - Top: finalTop, - Width: finalWidth, - Height: finalHeight - ); - - chart = shape.Chart; - - // Set data source to table's range - chart.SetSourceData(tableRange); - - // Set custom name if provided - if (!string.IsNullOrWhiteSpace(chartName)) - { - shape.Name = chartName; - } - - string finalName = shape.Name?.ToString() ?? "Chart"; - - // Collision detection - var warnings = ChartPositionHelpers.DetectCollisions( - worksheet, finalLeft, finalTop, finalWidth, finalHeight, finalName); - int chartCount = ChartPositionHelpers.CountCharts(worksheet); - - var result = new ChartCreateResult - { - Success = true, - ChartName = finalName, - SheetName = sheetName, - ChartType = chartType, - IsPivotChart = false, - Left = finalLeft, - Top = finalTop, - Width = finalWidth, - Height = finalHeight, - Message = ChartPositionHelpers.FormatCollisionWarnings(warnings, chartCount) - }; - - return result; - } - finally - { - ComUtilities.Release(ref targetRangeObj!); - ComUtilities.Release(ref chart!); - ComUtilities.Release(ref shape!); - ComUtilities.Release(ref shapes!); - ComUtilities.Release(ref worksheet!); - ComUtilities.Release(ref tableRange!); - ComUtilities.Release(ref table!); - } - }); - } - - /// <inheritdoc /> - public ChartCreateResult CreateFromPivotTable( - IExcelBatch batch, - string pivotTableName, - string sheetName, - ChartType chartType, - double left = 0, - double top = 0, - double width = 400, - double height = 300, - string? chartName = null, - string? targetRange = null) - { - return batch.Execute((ctx, ct) => - { - dynamic? worksheet = null; - dynamic? pivotChartShape = null; - dynamic? chart = null; - dynamic? pivotTable = null; - dynamic? tableRange = null; - dynamic? shapes = null; - dynamic? targetRangeObj = null; - - try - { - // Find PivotTable - pivotTable = FindPivotTable(ctx.Book, pivotTableName); - if (pivotTable == null) - { - throw new InvalidOperationException($"PivotTable '{pivotTableName}' not found in workbook."); - } - - // Get target worksheet - worksheet = ctx.Book.Worksheets[sheetName]; - - // Resolve final position: targetRange > explicit left/top > auto-position - double finalLeft = left; - double finalTop = top; - double finalWidth = width; - double finalHeight = height; - - if (!string.IsNullOrWhiteSpace(targetRange)) - { - targetRangeObj = worksheet.Range[targetRange]; - finalLeft = Convert.ToDouble(targetRangeObj.Left); - finalTop = Convert.ToDouble(targetRangeObj.Top); - finalWidth = Convert.ToDouble(targetRangeObj.Width); - finalHeight = Convert.ToDouble(targetRangeObj.Height); - } - else if (left == 0 && top == 0) - { - // Cast explicitly to avoid dynamic dispatch losing named tuple members - (double Left, double Top) autoPos = ChartPositionHelpers.FindAvailablePosition(worksheet, width, height); - finalLeft = autoPos.Left; - finalTop = autoPos.Top; - } - - // Create a chart via Shapes.AddChart and set source to PivotTable's range. - // This approach works for both OLAP (Data Model) and non-OLAP PivotTables, - // unlike PivotCache.CreatePivotChart which has parameter issues in dynamic - // COM and throws DISP_E_UNKNOWNNAME for OLAP sources. - shapes = worksheet.Shapes; - - // Create chart using AddChart - pivotChartShape = shapes.AddChart( - XlChartType: (int)chartType, - Left: finalLeft, - Top: finalTop, - Width: finalWidth, - Height: finalHeight - ); - - chart = pivotChartShape.Chart; - - // Get the PivotTable's data range and set it as the chart's source - tableRange = pivotTable.TableRange1; - chart.SetSourceData(tableRange); - - // Set custom name if provided - if (!string.IsNullOrWhiteSpace(chartName)) - { - pivotChartShape.Name = chartName; - } - - string finalName = pivotChartShape.Name?.ToString() ?? "Chart"; - - // Collision detection - var warnings = ChartPositionHelpers.DetectCollisions( - worksheet, finalLeft, finalTop, finalWidth, finalHeight, finalName); - int chartCount = ChartPositionHelpers.CountCharts(worksheet); - - var result = new ChartCreateResult - { - Success = true, - ChartName = finalName, - SheetName = sheetName, - ChartType = chartType, - IsPivotChart = true, - LinkedPivotTable = pivotTableName, - Left = finalLeft, - Top = finalTop, - Width = finalWidth, - Height = finalHeight, - Message = ChartPositionHelpers.FormatCollisionWarnings(warnings, chartCount) - }; - - return result; - } - finally - { - ComUtilities.Release(ref targetRangeObj!); - ComUtilities.Release(ref chart!); - ComUtilities.Release(ref pivotChartShape!); - ComUtilities.Release(ref tableRange!); - ComUtilities.Release(ref shapes!); - ComUtilities.Release(ref worksheet!); - ComUtilities.Release(ref pivotTable!); - } - }); - } - - /// <inheritdoc /> - public OperationResult Delete(IExcelBatch batch, string chartName) - { - return batch.Execute((ctx, ct) => - { - // Find and delete chart - dynamic worksheets = ctx.Book.Worksheets; - int wsCount = Convert.ToInt32(worksheets.Count); - - for (int i = 1; i <= wsCount; i++) - { - dynamic? worksheet = null; - dynamic? shapes = null; - - try - { - worksheet = worksheets.Item(i); - shapes = worksheet.Shapes; - int shapeCount = Convert.ToInt32(shapes.Count); - - for (int j = 1; j <= shapeCount; j++) - { - dynamic? shape = null; - - try - { - shape = shapes.Item(j); - - // Check if this is a chart and name matches - if (Convert.ToInt32(shape.Type) != 3) - { - continue; - } - - string shapeName = shape.Name?.ToString() ?? string.Empty; - if (!shapeName.Equals(chartName, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - // Delete the chart - shape.Delete(); - - ComUtilities.Release(ref shape!); - ComUtilities.Release(ref shapes!); - ComUtilities.Release(ref worksheet!); - ComUtilities.Release(ref worksheets!); - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; // Success - } - finally - { - ComUtilities.Release(ref shape!); - } - } - } - finally - { - ComUtilities.Release(ref shapes!); - ComUtilities.Release(ref worksheet!); - } - } - - ComUtilities.Release(ref worksheets!); - - // Chart not found - throw new InvalidOperationException($"Chart '{chartName}' not found in workbook."); - }); - } - - /// <inheritdoc /> - public OperationResult Move( - IExcelBatch batch, - string chartName, - double? left = null, - double? top = null, - double? width = null, - double? height = null) - { - return batch.Execute((ctx, ct) => - { - // Find chart and update position/size - dynamic worksheets = ctx.Book.Worksheets; - int wsCount = Convert.ToInt32(worksheets.Count); - - for (int i = 1; i <= wsCount; i++) - { - dynamic? worksheet = null; - dynamic? shapes = null; - - try - { - worksheet = worksheets.Item(i); - shapes = worksheet.Shapes; - int shapeCount = Convert.ToInt32(shapes.Count); - - for (int j = 1; j <= shapeCount; j++) - { - dynamic? shape = null; - - try - { - shape = shapes.Item(j); - - // Check if this is a chart and name matches - if (Convert.ToInt32(shape.Type) != 3) - { - continue; - } - - string shapeName = shape.Name?.ToString() ?? string.Empty; - if (!shapeName.Equals(chartName, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - // Update position and size - if (left.HasValue) shape.Left = left.Value; - if (top.HasValue) shape.Top = top.Value; - if (width.HasValue) shape.Width = width.Value; - if (height.HasValue) shape.Height = height.Value; - - // Collision detection after repositioning - double finalLeft = Convert.ToDouble(shape.Left); - double finalTop = Convert.ToDouble(shape.Top); - double finalWidth = Convert.ToDouble(shape.Width); - double finalHeight = Convert.ToDouble(shape.Height); - - var warnings = ChartPositionHelpers.DetectCollisions( - worksheet, finalLeft, finalTop, finalWidth, finalHeight, shapeName); - int chartCount = ChartPositionHelpers.CountCharts(worksheet); - - ComUtilities.Release(ref shape!); - ComUtilities.Release(ref shapes!); - ComUtilities.Release(ref worksheet!); - ComUtilities.Release(ref worksheets!); - - return new OperationResult - { - Success = true, - FilePath = batch.WorkbookPath, - Message = ChartPositionHelpers.FormatCollisionWarnings(warnings, chartCount) - }; - } - finally - { - ComUtilities.Release(ref shape!); - } - } - } - finally - { - ComUtilities.Release(ref shapes!); - ComUtilities.Release(ref worksheet!); - } - } - - ComUtilities.Release(ref worksheets!); - - // Chart not found - throw new InvalidOperationException($"Chart '{chartName}' not found in workbook."); - }); - } - - /// <summary> - /// Finds a PivotTable by name across all worksheets. - /// Delegates to CoreLookupHelpers.TryFindPivotTable for the actual lookup. - /// </summary> - private static dynamic? FindPivotTable(dynamic workbook, string pivotTableName) - { - CoreLookupHelpers.TryFindPivotTable(workbook, pivotTableName, out dynamic? pivotTable); - return pivotTable; - } -} - - diff --git a/src/ExcelMcp.Core/Commands/Chart/ChartPositionHelpers.cs b/src/ExcelMcp.Core/Commands/Chart/ChartPositionHelpers.cs deleted file mode 100644 index d1f2fabf..00000000 --- a/src/ExcelMcp.Core/Commands/Chart/ChartPositionHelpers.cs +++ /dev/null @@ -1,298 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; - -namespace Sbroenne.ExcelMcp.Core.Commands.Chart; - -/// <summary> -/// Helpers for chart positioning: collision detection against data ranges and other charts. -/// </summary> -internal static class ChartPositionHelpers -{ - /// <summary> - /// Detects collisions between a chart's proposed position and existing content on the worksheet. - /// Checks against the used range (data) and all other chart shapes. - /// </summary> - /// <param name="worksheet">Target worksheet COM object</param> - /// <param name="left">Proposed chart left position in points</param> - /// <param name="top">Proposed chart top position in points</param> - /// <param name="width">Proposed chart width in points</param> - /// <param name="height">Proposed chart height in points</param> - /// <param name="excludeChartName">Chart name to exclude from collision checks (for move/resize operations on an existing chart)</param> - /// <returns>List of collision warning messages, empty if no collisions</returns> - internal static List<string> DetectCollisions( - dynamic worksheet, - double left, - double top, - double width, - double height, - string? excludeChartName = null) - { - var warnings = new List<string>(); - - // Check collision with used range (data area) - CheckUsedRangeCollision(worksheet, left, top, width, height, warnings); - - // Check collision with other charts - CheckChartCollisions(worksheet, left, top, width, height, excludeChartName, warnings); - - return warnings; - } - - /// <summary> - /// Finds the first available position below or to the right of all existing content (used range + charts) - /// on the worksheet for a chart with the given dimensions. - /// </summary> - /// <param name="worksheet">Target worksheet COM object</param> - /// <param name="width">Desired chart width in points</param> - /// <param name="height">Desired chart height in points</param> - /// <param name="padding">Padding in points between content and chart (default: 10pt)</param> - /// <returns>Tuple of (left, top) position in points for the chart</returns> - internal static (double Left, double Top) FindAvailablePosition( - dynamic worksheet, - double width = 400, - double height = 300, - double padding = 10.0) - { - double maxBottom = 0; - double maxRight = 0; - - // Get used range boundary - dynamic? usedRange = null; - try - { - usedRange = worksheet.UsedRange; - double urLeft = Convert.ToDouble(usedRange.Left); - double urTop = Convert.ToDouble(usedRange.Top); - double urWidth = Convert.ToDouble(usedRange.Width); - double urHeight = Convert.ToDouble(usedRange.Height); - - maxBottom = Math.Max(maxBottom, urTop + urHeight); - maxRight = Math.Max(maxRight, urLeft + urWidth); - } - finally - { - ComUtilities.Release(ref usedRange!); - } - - // Get chart boundaries - dynamic? shapes = null; - try - { - shapes = worksheet.Shapes; - int shapeCount = Convert.ToInt32(shapes.Count); - - for (int j = 1; j <= shapeCount; j++) - { - dynamic? shape = null; - try - { - shape = shapes.Item(j); - - // Only consider chart shapes (msoChart = 3) - if (Convert.ToInt32(shape.Type) != 3) - { - continue; - } - - double sLeft = Convert.ToDouble(shape.Left); - double sTop = Convert.ToDouble(shape.Top); - double sWidth = Convert.ToDouble(shape.Width); - double sHeight = Convert.ToDouble(shape.Height); - - maxBottom = Math.Max(maxBottom, sTop + sHeight); - maxRight = Math.Max(maxRight, sLeft + sWidth); - } - finally - { - ComUtilities.Release(ref shape!); - } - } - } - finally - { - ComUtilities.Release(ref shapes!); - } - - // Strategy: place chart below all existing content, aligned to left edge of used range - // If the chart would fit to the right of the used range (within reasonable horizontal space), - // place it there instead for a side-by-side layout. - // For now, always place below to avoid horizontal overflow. - _ = width; // Reserved for future side-by-side layout consideration - _ = height; // Reserved for future vertical space check - - return (padding, maxBottom + padding); - } - - /// <summary> - /// Counts the number of chart shapes on a worksheet. - /// </summary> - internal static int CountCharts(dynamic worksheet) - { - int count = 0; - dynamic? shapes = null; - try - { - shapes = worksheet.Shapes; - int shapeCount = Convert.ToInt32(shapes.Count); - - for (int j = 1; j <= shapeCount; j++) - { - dynamic? shape = null; - try - { - shape = shapes.Item(j); - if (Convert.ToInt32(shape.Type) == 3) // msoChart - { - count++; - } - } - finally - { - ComUtilities.Release(ref shape!); - } - } - } - finally - { - ComUtilities.Release(ref shapes!); - } - - return count; - } - - /// <summary> - /// Formats chart positioning feedback for the result message. - /// Always includes a screenshot verification reminder. - /// When collisions are detected, includes overlap warnings with remediation guidance. - /// When multiple charts exist on the sheet, uses stronger language to ensure screenshot verification. - /// </summary> - /// <param name="warnings">Collision warnings (empty if no overlaps detected)</param> - /// <param name="chartCount">Number of charts on the worksheet (including the one just created/moved)</param> - internal static string FormatCollisionWarnings(List<string> warnings, int chartCount = 1) - { - if (warnings.Count > 0) - { - return $"OVERLAP WARNING: {string.Join("; ", warnings)}. Use chart move or fit-to-range to reposition, then screenshot(capture-sheet) to verify layout."; - } - - if (chartCount >= 2) - { - return $"IMPORTANT: {chartCount} charts now on this sheet. You MUST take a screenshot(capture-sheet) to verify no charts overlap each other or the data."; - } - - return "IMPORTANT: You MUST take a screenshot(capture-sheet) to verify the chart does not overlap the data."; - } - - private static void CheckUsedRangeCollision( - dynamic worksheet, - double left, - double top, - double width, - double height, - List<string> warnings) - { - dynamic? usedRange = null; - try - { - usedRange = worksheet.UsedRange; - - double urLeft = Convert.ToDouble(usedRange.Left); - double urTop = Convert.ToDouble(usedRange.Top); - double urWidth = Convert.ToDouble(usedRange.Width); - double urHeight = Convert.ToDouble(usedRange.Height); - - string? urAddress = null; - try - { - urAddress = usedRange.Address?.ToString(); - } - catch - { - urAddress = "(unknown)"; - } - - if (RectsOverlap(left, top, width, height, urLeft, urTop, urWidth, urHeight)) - { - warnings.Add($"Chart overlaps data area {urAddress}"); - } - } - finally - { - ComUtilities.Release(ref usedRange!); - } - } - - private static void CheckChartCollisions( - dynamic worksheet, - double left, - double top, - double width, - double height, - string? excludeChartName, - List<string> warnings) - { - dynamic? shapes = null; - try - { - shapes = worksheet.Shapes; - int shapeCount = Convert.ToInt32(shapes.Count); - - for (int j = 1; j <= shapeCount; j++) - { - dynamic? shape = null; - try - { - shape = shapes.Item(j); - - // Only check chart shapes (msoChart = 3) - if (Convert.ToInt32(shape.Type) != 3) - { - continue; - } - - string shapeName = shape.Name?.ToString() ?? string.Empty; - - // Skip the chart being created/moved - if (excludeChartName != null && - shapeName.Equals(excludeChartName, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - double sLeft = Convert.ToDouble(shape.Left); - double sTop = Convert.ToDouble(shape.Top); - double sWidth = Convert.ToDouble(shape.Width); - double sHeight = Convert.ToDouble(shape.Height); - - if (RectsOverlap(left, top, width, height, sLeft, sTop, sWidth, sHeight)) - { - warnings.Add($"Chart overlaps existing chart '{shapeName}'"); - } - } - finally - { - ComUtilities.Release(ref shape!); - } - } - } - finally - { - ComUtilities.Release(ref shapes!); - } - } - - /// <summary> - /// Checks if two rectangles overlap (in point coordinates). - /// Returns false if they merely touch edges (share a boundary). - /// </summary> - private static bool RectsOverlap( - double x1, double y1, double w1, double h1, - double x2, double y2, double w2, double h2) - { - // No overlap if one is completely to the left, right, above, or below the other - // Use strict < (not <=) so touching edges are NOT considered overlaps - return x1 < x2 + w2 && - x1 + w1 > x2 && - y1 < y2 + h2 && - y1 + h1 > y2; - } -} diff --git a/src/ExcelMcp.Core/Commands/Chart/ChartResults.cs b/src/ExcelMcp.Core/Commands/Chart/ChartResults.cs deleted file mode 100644 index 8d9d83e4..00000000 --- a/src/ExcelMcp.Core/Commands/Chart/ChartResults.cs +++ /dev/null @@ -1,393 +0,0 @@ -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.Chart; - -/// <summary> -/// Result containing list of charts in workbook. -/// </summary> -public class ChartListResult : OperationResult -{ - /// <summary> - /// List of charts (Regular and PivotCharts). - /// </summary> - public List<ChartInfo> Charts { get; set; } = new(); -} - -/// <summary> -/// Information about a chart. -/// </summary> -public class ChartInfo -{ - /// <summary>Chart or shape name</summary> - public string Name { get; set; } = string.Empty; - - /// <summary>Worksheet containing the chart</summary> - public string SheetName { get; set; } = string.Empty; - - /// <summary>Chart type (column, line, pie, etc.)</summary> - public ChartType ChartType { get; set; } - - /// <summary>True if this is a PivotChart, false if Regular Chart</summary> - public bool IsPivotChart { get; set; } - - /// <summary>Name of linked PivotTable (PivotCharts only)</summary> - public string? LinkedPivotTable { get; set; } - - /// <summary>Left position in points</summary> - public double Left { get; set; } - - /// <summary>Top position in points</summary> - public double Top { get; set; } - - /// <summary>Width in points</summary> - public double Width { get; set; } - - /// <summary>Height in points</summary> - public double Height { get; set; } - - /// <summary>Number of data series</summary> - public int SeriesCount { get; set; } - - /// <summary> - /// Cell address of top-left anchor (e.g., "$A$1"). - /// Chart's top-left corner overlaps this cell. - /// </summary> - public string? TopLeftCell { get; set; } - - /// <summary> - /// Cell address of bottom-right anchor (e.g., "$D$10"). - /// Chart's bottom-right corner overlaps this cell. - /// </summary> - public string? BottomRightCell { get; set; } - - /// <summary> - /// Chart placement mode: 1=Move and size with cells, 2=Move but don't size with cells, 3=Don't move or size with cells - /// </summary> - public int? Placement { get; set; } -} - -/// <summary> -/// Result containing complete chart configuration. -/// </summary> -public class ChartInfoResult : OperationResult -{ - /// <summary>Chart or shape name</summary> - public string Name { get; set; } = string.Empty; - - /// <summary>Worksheet containing the chart</summary> - public string SheetName { get; set; } = string.Empty; - - /// <summary>Chart type (column, line, pie, etc.)</summary> - public ChartType ChartType { get; set; } - - /// <summary>True if this is a PivotChart, false if Regular Chart</summary> - public bool IsPivotChart { get; set; } - - /// <summary>Name of linked PivotTable (PivotCharts only)</summary> - public string? LinkedPivotTable { get; set; } - - /// <summary>Source range for Regular Charts (e.g., "Sheet1!A1:D10")</summary> - public string? SourceRange { get; set; } - - /// <summary>Left position in points</summary> - public double Left { get; set; } - - /// <summary>Top position in points</summary> - public double Top { get; set; } - - /// <summary>Width in points</summary> - public double Width { get; set; } - - /// <summary>Height in points</summary> - public double Height { get; set; } - - /// <summary> - /// Cell address of top-left anchor (e.g., "$A$1"). - /// Chart's top-left corner overlaps this cell. - /// </summary> - public string? TopLeftCell { get; set; } - - /// <summary> - /// Cell address of bottom-right anchor (e.g., "$D$10"). - /// Chart's bottom-right corner overlaps this cell. - /// </summary> - public string? BottomRightCell { get; set; } - - /// <summary> - /// Chart placement mode: 1=Move and size with cells, 2=Move but don't size with cells, 3=Don't move or size with cells - /// </summary> - public int? Placement { get; set; } - - /// <summary>Chart title text</summary> - public string? Title { get; set; } - - /// <summary>True if legend is visible</summary> - public bool HasLegend { get; set; } - - /// <summary>Data series (Regular Charts only)</summary> - public List<SeriesInfo> Series { get; set; } = new(); -} - -/// <summary> -/// Information about a chart data series. -/// </summary> -public class SeriesInfo -{ - /// <summary>Series name</summary> - public string Name { get; set; } = string.Empty; - - /// <summary>Range containing Y values</summary> - public string ValuesRange { get; set; } = string.Empty; - - /// <summary>Range containing X values/categories (optional)</summary> - public string? CategoryRange { get; set; } -} - -/// <summary> -/// Result from chart creation operations. -/// </summary> -public class ChartCreateResult : OperationResult -{ - /// <summary>Name of the created chart</summary> - public string ChartName { get; set; } = string.Empty; - - /// <summary>Worksheet containing the chart</summary> - public string SheetName { get; set; } = string.Empty; - - /// <summary>Chart type</summary> - public ChartType ChartType { get; set; } - - /// <summary>True if this is a PivotChart, false if Regular Chart</summary> - public bool IsPivotChart { get; set; } - - /// <summary>Name of linked PivotTable (PivotCharts only)</summary> - public string? LinkedPivotTable { get; set; } - - /// <summary>Left position in points</summary> - public double Left { get; set; } - - /// <summary>Top position in points</summary> - public double Top { get; set; } - - /// <summary>Width in points</summary> - public double Width { get; set; } - - /// <summary>Height in points</summary> - public double Height { get; set; } -} - -/// <summary> -/// Result from series operations. -/// </summary> -public class ChartSeriesResult : OperationResult -{ - /// <summary>Series name</summary> - public string SeriesName { get; set; } = string.Empty; - - /// <summary>Range containing Y values</summary> - public string ValuesRange { get; set; } = string.Empty; - - /// <summary>Range containing X values/categories (optional)</summary> - public string? CategoryRange { get; set; } - - /// <summary>1-based series index</summary> - public int SeriesIndex { get; set; } -} - -/// <summary> -/// Result containing axis scale information. -/// </summary> -public class AxisScaleResult : OperationResult -{ - /// <summary>Chart name</summary> - public string ChartName { get; set; } = string.Empty; - - /// <summary>Axis type (Value, Category, ValueSecondary, CategorySecondary)</summary> - public string AxisType { get; set; } = string.Empty; - - /// <summary>Minimum scale value (null if auto)</summary> - public double? MinimumScale { get; set; } - - /// <summary>Maximum scale value (null if auto)</summary> - public double? MaximumScale { get; set; } - - /// <summary>True if minimum scale is automatic</summary> - public bool MinimumScaleIsAuto { get; set; } - - /// <summary>True if maximum scale is automatic</summary> - public bool MaximumScaleIsAuto { get; set; } - - /// <summary>Major unit (distance between major gridlines/tick marks)</summary> - public double? MajorUnit { get; set; } - - /// <summary>Minor unit (distance between minor gridlines/tick marks)</summary> - public double? MinorUnit { get; set; } - - /// <summary>True if major unit is automatic</summary> - public bool MajorUnitIsAuto { get; set; } - - /// <summary>True if minor unit is automatic</summary> - public bool MinorUnitIsAuto { get; set; } -} - -/// <summary> -/// Information about chart data labels. -/// </summary> -public class DataLabelsInfo -{ - /// <summary>Show the actual value</summary> - public bool ShowValue { get; set; } - - /// <summary>Show percentage (pie/doughnut charts)</summary> - public bool ShowPercentage { get; set; } - - /// <summary>Show series name</summary> - public bool ShowSeriesName { get; set; } - - /// <summary>Show category name</summary> - public bool ShowCategoryName { get; set; } - - /// <summary>Show bubble size (bubble charts)</summary> - public bool ShowBubbleSize { get; set; } - - /// <summary>Separator between label parts (e.g., ", " or newline)</summary> - public string? Separator { get; set; } - - /// <summary>Position of data labels</summary> - public string? Position { get; set; } -} - -/// <summary> -/// Information about chart gridlines. -/// </summary> -public class GridlinesInfo -{ - /// <summary>True if major gridlines are visible on primary value axis</summary> - public bool HasValueMajorGridlines { get; set; } - - /// <summary>True if minor gridlines are visible on primary value axis</summary> - public bool HasValueMinorGridlines { get; set; } - - /// <summary>True if major gridlines are visible on category axis</summary> - public bool HasCategoryMajorGridlines { get; set; } - - /// <summary>True if minor gridlines are visible on category axis</summary> - public bool HasCategoryMinorGridlines { get; set; } -} - -/// <summary> -/// Result containing gridlines information. -/// </summary> -public class GridlinesResult : OperationResult -{ - /// <summary>Chart name</summary> - public string ChartName { get; set; } = string.Empty; - - /// <summary>Gridlines configuration</summary> - public GridlinesInfo Gridlines { get; set; } = new(); -} - -/// <summary> -/// Information about series marker formatting. -/// </summary> -public class SeriesFormatInfo -{ - /// <summary>1-based series index</summary> - public int SeriesIndex { get; set; } - - /// <summary>Series name</summary> - public string SeriesName { get; set; } = string.Empty; - - /// <summary>Marker style (none, square, diamond, triangle, x, star, circle, plus, etc.)</summary> - public string? MarkerStyle { get; set; } - - /// <summary>Marker size (2-72 points)</summary> - public int? MarkerSize { get; set; } - - /// <summary>Marker background color (#RRGGBB hex)</summary> - public string? MarkerBackgroundColor { get; set; } - - /// <summary>Marker foreground/border color (#RRGGBB hex)</summary> - public string? MarkerForegroundColor { get; set; } - - /// <summary>True to invert colors for negative values</summary> - public bool? InvertIfNegative { get; set; } -} - -/// <summary> -/// Information about a trendline on a chart series. -/// </summary> -public class TrendlineInfo -{ - /// <summary>1-based trendline index within the series</summary> - public int Index { get; set; } - - /// <summary>Trendline type (Linear, Exponential, etc.)</summary> - public TrendlineType Type { get; set; } - - /// <summary>Custom name for the trendline</summary> - public string? Name { get; set; } - - /// <summary>Polynomial order (2-6) when type is Polynomial</summary> - public int? Order { get; set; } - - /// <summary>Moving average period when type is MovingAverage</summary> - public int? Period { get; set; } - - /// <summary>Number of periods to forecast forward</summary> - public double? Forward { get; set; } - - /// <summary>Number of periods to forecast backward</summary> - public double? Backward { get; set; } - - /// <summary>Y-intercept value (null = calculated)</summary> - public double? Intercept { get; set; } - - /// <summary>True if equation is displayed on chart</summary> - public bool DisplayEquation { get; set; } - - /// <summary>True if R-squared value is displayed on chart</summary> - public bool DisplayRSquared { get; set; } -} - -/// <summary> -/// Result containing list of trendlines for a series. -/// </summary> -public class TrendlineListResult : OperationResult -{ - /// <summary>Chart name</summary> - public string ChartName { get; set; } = string.Empty; - - /// <summary>1-based series index</summary> - public int SeriesIndex { get; set; } - - /// <summary>Series name</summary> - public string SeriesName { get; set; } = string.Empty; - - /// <summary>List of trendlines on the series</summary> - public List<TrendlineInfo> Trendlines { get; set; } = new(); -} - -/// <summary> -/// Result from adding a trendline. -/// </summary> -public class TrendlineResult : OperationResult -{ - /// <summary>Chart name</summary> - public string ChartName { get; set; } = string.Empty; - - /// <summary>1-based series index</summary> - public int SeriesIndex { get; set; } - - /// <summary>1-based trendline index within the series</summary> - public int TrendlineIndex { get; set; } - - /// <summary>Trendline type</summary> - public TrendlineType Type { get; set; } - - /// <summary>Custom name for the trendline</summary> - public string? Name { get; set; } -} - - - diff --git a/src/ExcelMcp.Core/Commands/Chart/ChartType.cs b/src/ExcelMcp.Core/Commands/Chart/ChartType.cs deleted file mode 100644 index ebf1bb16..00000000 --- a/src/ExcelMcp.Core/Commands/Chart/ChartType.cs +++ /dev/null @@ -1,289 +0,0 @@ -namespace Sbroenne.ExcelMcp.Core.Commands.Chart; - -/// <summary> -/// Excel chart types - All 70+ values grouped by category. -/// Excel COM: XlChartType enumeration. -/// Reference: https://learn.microsoft.com/office/vba/api/excel.xlcharttype -/// </summary> -public enum ChartType -{ - // === COLUMN CHARTS === - - /// <summary>Clustered column chart (xlColumnClustered)</summary> - ColumnClustered = 51, - - /// <summary>Stacked column chart (xlColumnStacked)</summary> - ColumnStacked = 52, - - /// <summary>100% stacked column chart (xlColumnStacked100)</summary> - ColumnStacked100 = 53, - - /// <summary>3D clustered column chart (xl3DColumnClustered)</summary> - Column3DClustered = 54, - - /// <summary>3D stacked column chart (xl3DColumnStacked)</summary> - Column3DStacked = 55, - - /// <summary>3D 100% stacked column chart (xl3DColumnStacked100)</summary> - Column3DStacked100 = 56, - - /// <summary>3D column chart (xl3DColumn)</summary> - Column3D = -4100, - - // === BAR CHARTS === - - /// <summary>Clustered bar chart (xlBarClustered)</summary> - BarClustered = 57, - - /// <summary>Stacked bar chart (xlBarStacked)</summary> - BarStacked = 58, - - /// <summary>100% stacked bar chart (xlBarStacked100)</summary> - BarStacked100 = 59, - - /// <summary>3D clustered bar chart (xl3DBarClustered)</summary> - Bar3DClustered = 60, - - /// <summary>3D stacked bar chart (xl3DBarStacked)</summary> - Bar3DStacked = 61, - - /// <summary>3D 100% stacked bar chart (xl3DBarStacked100)</summary> - Bar3DStacked100 = 62, - - // === LINE CHARTS === - - /// <summary>Line chart (xlLine)</summary> - Line = 4, - - /// <summary>Stacked line chart (xlLineStacked)</summary> - LineStacked = 63, - - /// <summary>100% stacked line chart (xlLineStacked100)</summary> - LineStacked100 = 64, - - /// <summary>Line chart with markers (xlLineMarkers)</summary> - LineMarkers = 65, - - /// <summary>Stacked line chart with markers (xlLineMarkersStacked)</summary> - LineMarkersStacked = 66, - - /// <summary>100% stacked line chart with markers (xlLineMarkersStacked100)</summary> - LineMarkersStacked100 = 67, - - /// <summary>3D line chart (xl3DLine)</summary> - Line3D = -4101, - - // === PIE CHARTS === - - /// <summary>Pie chart (xlPie)</summary> - Pie = 5, - - /// <summary>3D pie chart (xl3DPie)</summary> - Pie3D = -4102, - - /// <summary>Pie of pie chart (xlPieOfPie)</summary> - PieOfPie = 68, - - /// <summary>Exploded pie chart (xlPieExploded)</summary> - PieExploded = 69, - - /// <summary>3D exploded pie chart (xl3DPieExploded)</summary> - PieExploded3D = 70, - - /// <summary>Bar of pie chart (xlBarOfPie)</summary> - BarOfPie = 71, - - // === SCATTER (XY) CHARTS === - - /// <summary>Scatter chart (xlXYScatter)</summary> - XYScatter = -4169, - - /// <summary>Scatter chart with smooth lines (xlXYScatterSmooth)</summary> - XYScatterSmooth = 72, - - /// <summary>Scatter chart with smooth lines and no markers (xlXYScatterSmoothNoMarkers)</summary> - XYScatterSmoothNoMarkers = 73, - - /// <summary>Scatter chart with lines (xlXYScatterLines)</summary> - XYScatterLines = 74, - - /// <summary>Scatter chart with lines and no markers (xlXYScatterLinesNoMarkers)</summary> - XYScatterLinesNoMarkers = 75, - - // === AREA CHARTS === - - /// <summary>Area chart (xlArea)</summary> - Area = 1, - - /// <summary>Stacked area chart (xlAreaStacked)</summary> - AreaStacked = 76, - - /// <summary>100% stacked area chart (xlAreaStacked100)</summary> - AreaStacked100 = 77, - - /// <summary>3D area chart (xl3DArea)</summary> - Area3D = -4098, - - /// <summary>3D stacked area chart (xl3DAreaStacked)</summary> - Area3DStacked = 78, - - /// <summary>3D 100% stacked area chart (xl3DAreaStacked100)</summary> - Area3DStacked100 = 79, - - // === DOUGHNUT CHARTS === - - /// <summary>Doughnut chart (xlDoughnut)</summary> - Doughnut = -4120, - - /// <summary>Exploded doughnut chart (xlDoughnutExploded)</summary> - DoughnutExploded = 80, - - // === RADAR CHARTS === - - /// <summary>Radar chart (xlRadar)</summary> - Radar = -4151, - - /// <summary>Radar chart with markers (xlRadarMarkers)</summary> - RadarMarkers = 81, - - /// <summary>Filled radar chart (xlRadarFilled)</summary> - RadarFilled = 82, - - // === SURFACE CHARTS === - - /// <summary>Surface chart (xlSurface)</summary> - Surface = 83, - - /// <summary>Wireframe surface chart (xlSurfaceWireframe)</summary> - SurfaceWireframe = 84, - - /// <summary>Top view surface chart (xlSurfaceTopView)</summary> - SurfaceTopView = 85, - - /// <summary>Top view wireframe surface chart (xlSurfaceTopViewWireframe)</summary> - SurfaceTopViewWireframe = 86, - - // === BUBBLE CHARTS === - - /// <summary>Bubble chart (xlBubble)</summary> - Bubble = 15, - - /// <summary>Bubble chart with 3D effect (xlBubble3DEffect)</summary> - Bubble3DEffect = 87, - - // === STOCK CHARTS === - - /// <summary>Stock chart (High-Low-Close) (xlStockHLC)</summary> - StockHLC = 88, - - /// <summary>Stock chart (Open-High-Low-Close) (xlStockOHLC)</summary> - StockOHLC = 89, - - /// <summary>Stock chart (Volume-High-Low-Close) (xlStockVHLC)</summary> - StockVHLC = 90, - - /// <summary>Stock chart (Volume-Open-High-Low-Close) (xlStockVOHLC)</summary> - StockVOHLC = 91, - - // === CYLINDER CHARTS === - - /// <summary>Clustered cylinder bar chart (xlCylinderBarClustered)</summary> - CylinderBarClustered = 95, - - /// <summary>Stacked cylinder bar chart (xlCylinderBarStacked)</summary> - CylinderBarStacked = 96, - - /// <summary>100% stacked cylinder bar chart (xlCylinderBarStacked100)</summary> - CylinderBarStacked100 = 97, - - /// <summary>Cylinder column chart (xlCylinderCol)</summary> - CylinderCol = 98, - - /// <summary>Clustered cylinder column chart (xlCylinderColClustered)</summary> - CylinderColClustered = 92, - - /// <summary>Stacked cylinder column chart (xlCylinderColStacked)</summary> - CylinderColStacked = 93, - - /// <summary>100% stacked cylinder column chart (xlCylinderColStacked100)</summary> - CylinderColStacked100 = 94, - - // === CONE CHARTS === - - /// <summary>Clustered cone bar chart (xlConeBarClustered)</summary> - ConeBarClustered = 102, - - /// <summary>Stacked cone bar chart (xlConeBarStacked)</summary> - ConeBarStacked = 103, - - /// <summary>100% stacked cone bar chart (xlConeBarStacked100)</summary> - ConeBarStacked100 = 104, - - /// <summary>Cone column chart (xlConeCol)</summary> - ConeCol = 105, - - /// <summary>Clustered cone column chart (xlConeColClustered)</summary> - ConeColClustered = 99, - - /// <summary>Stacked cone column chart (xlConeColStacked)</summary> - ConeColStacked = 100, - - /// <summary>100% stacked cone column chart (xlConeColStacked100)</summary> - ConeColStacked100 = 101, - - // === PYRAMID CHARTS === - - /// <summary>Clustered pyramid bar chart (xlPyramidBarClustered)</summary> - PyramidBarClustered = 109, - - /// <summary>Stacked pyramid bar chart (xlPyramidBarStacked)</summary> - PyramidBarStacked = 110, - - /// <summary>100% stacked pyramid bar chart (xlPyramidBarStacked100)</summary> - PyramidBarStacked100 = 111, - - /// <summary>Pyramid column chart (xlPyramidCol)</summary> - PyramidCol = 112, - - /// <summary>Clustered pyramid column chart (xlPyramidColClustered)</summary> - PyramidColClustered = 106, - - /// <summary>Stacked pyramid column chart (xlPyramidColStacked)</summary> - PyramidColStacked = 107, - - /// <summary>100% stacked pyramid column chart (xlPyramidColStacked100)</summary> - PyramidColStacked100 = 108, - - // === MODERN CHARTS (Excel 2016+) === - - /// <summary>Treemap chart (xlTreemap)</summary> - Treemap = 117, - - /// <summary>Sunburst chart (xlSunburst)</summary> - Sunburst = 116, - - /// <summary>Histogram chart (xlHistogram)</summary> - Histogram = 118, - - /// <summary>Pareto chart (xlPareto)</summary> - Pareto = 122, - - /// <summary>Box and whisker chart (xlBoxWhisker)</summary> - BoxWhisker = 121, - - /// <summary>Waterfall chart (xlWaterfall)</summary> - Waterfall = 119, - - /// <summary>Funnel chart (xlFunnel)</summary> - Funnel = 123, - - // === COMBO CHARTS === - - /// <summary>Column-line combo chart (xlColumnLineCombo - approximation)</summary> - ColumnLineCombo = 120, - - /// <summary>Region map chart (xlRegionMap - Excel 365)</summary> - RegionMap = 140 -} - - diff --git a/src/ExcelMcp.Core/Commands/Chart/DataLabelPosition.cs b/src/ExcelMcp.Core/Commands/Chart/DataLabelPosition.cs deleted file mode 100644 index de786d4c..00000000 --- a/src/ExcelMcp.Core/Commands/Chart/DataLabelPosition.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace Sbroenne.ExcelMcp.Core.Commands.Chart; - -/// <summary> -/// Data label position constants for Excel charts. -/// Excel COM: XlDataLabelPosition enumeration. -/// </summary> -public enum DataLabelPosition -{ - /// <summary>Best fit determined by Excel (xlLabelPositionBestFit)</summary> - BestFit = 5, - - /// <summary>Center of the data point (xlLabelPositionCenter)</summary> - Center = -4108, - - /// <summary>Above the data point (xlLabelPositionAbove)</summary> - Above = 0, - - /// <summary>Below the data point (xlLabelPositionBelow)</summary> - Below = 1, - - /// <summary>Left of the data point (xlLabelPositionLeft)</summary> - Left = -4131, - - /// <summary>Right of the data point (xlLabelPositionRight)</summary> - Right = -4152, - - /// <summary>Inside base of bar/column (xlLabelPositionInsideBase)</summary> - InsideBase = 4, - - /// <summary>Inside end of bar/column (xlLabelPositionInsideEnd)</summary> - InsideEnd = 3, - - /// <summary>Outside end of bar/column (xlLabelPositionOutsideEnd)</summary> - OutsideEnd = 2, - - /// <summary>Mixed positions (read-only)</summary> - Mixed = 6 -} - - diff --git a/src/ExcelMcp.Core/Commands/Chart/IChartCommands.cs b/src/ExcelMcp.Core/Commands/Chart/IChartCommands.cs deleted file mode 100644 index 37b74cc4..00000000 --- a/src/ExcelMcp.Core/Commands/Chart/IChartCommands.cs +++ /dev/null @@ -1,170 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Attributes; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.Chart; - -/// <summary> -/// Chart lifecycle - create, read, move, and delete embedded charts. -/// -/// POSITIONING (choose one): -/// - targetRange (PREFERRED): Cell range like 'F2:K15' — positions chart within cells, no point math needed. -/// - left/top: Manual positioning in points (72 points = 1 inch). -/// - Neither: Auto-positions chart below all existing content (used range + other charts). -/// -/// COLLISION DETECTION: All create/move/fit-to-range operations automatically check for overlaps -/// with data and other charts. Warnings are returned in the result message if collisions are detected. -/// Always verify layout with screenshot(capture-sheet) after creating charts. -/// -/// CHART TYPES: 70+ types available including Column, Line, Pie, Bar, Area, XY Scatter. -/// -/// CREATE OPTIONS: -/// - create-from-range: Create from cell range (e.g., 'A1:D10') -/// - create-from-table: Create from Excel Table (uses table's data range) -/// - create-from-pivottable: Create linked PivotChart -/// -/// Use chartconfig for series, titles, legends, styles, placement mode. -/// </summary> -[ServiceCategory("chart", "Chart")] -[McpTool("chart", Title = "Chart Operations", Destructive = true, Category = "analysis", - Description = "Chart lifecycle - create, read, move, and delete embedded charts. POSITIONING: targetRange='F2:K15' (PREFERRED, cell-relative) or left/top (points, 72pts=1in) or OMIT BOTH for auto-positioning below content. COLLISION DETECTION: Automatically warns if chart overlaps data or other charts. CHART TYPES: 70+ types (ColumnClustered, Line, Pie, Bar, Area, XYScatter, etc.). CREATE: create-from-range (cell range), create-from-table (Excel Table), create-from-pivottable (linked PivotChart). Use chart_config for series, titles, legends, and styling.")] -public interface IChartCommands -{ - // === LIFECYCLE OPERATIONS === - - /// <summary> - /// Lists all charts in workbook (Regular and PivotCharts). - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <returns>List of charts with names, types, sheets, positions, data sources</returns> - [ServiceAction("list")] - List<ChartInfo> List(IExcelBatch batch); - - /// <summary> - /// Gets complete chart configuration. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="chartName">Name of the chart (or shape name)</param> - /// <returns>Chart type, data source, series info, position, styling</returns> - [ServiceAction("read")] - ChartInfoResult Read(IExcelBatch batch, [RequiredParameter] string chartName); - - /// <summary> - /// Creates a Regular Chart from an Excel range. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Target worksheet name</param> - /// <param name="sourceRangeAddress">Data range for the chart (e.g., A1:D10)</param> - /// <param name="chartType">Type of chart to create</param> - /// <param name="left">Left position in points from worksheet edge</param> - /// <param name="top">Top position in points from worksheet edge</param> - /// <param name="width">Chart width in points</param> - /// <param name="height">Chart height in points</param> - /// <param name="chartName">Optional chart name (auto-generated if omitted)</param> - /// <param name="targetRange">Cell range to position chart within (e.g., 'F2:K15'). PREFERRED over left/top. When set, left/top are ignored.</param> - [ServiceAction("create-from-range")] - ChartCreateResult CreateFromRange( - IExcelBatch batch, - [RequiredParameter] string sheetName, - [RequiredParameter] string sourceRangeAddress, - [RequiredParameter] ChartType chartType, - double left = 0, - double top = 0, - double width = 400, - double height = 300, - string? chartName = null, - string? targetRange = null); - - /// <summary> - /// Creates a Regular Chart from an Excel Table's data range. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="tableName">Name of the Excel Table</param> - /// <param name="sheetName">Target worksheet name for the chart</param> - /// <param name="chartType">Type of chart to create</param> - /// <param name="left">Left position in points from worksheet edge</param> - /// <param name="top">Top position in points from worksheet edge</param> - /// <param name="width">Chart width in points</param> - /// <param name="height">Chart height in points</param> - /// <param name="chartName">Optional chart name (auto-generated if omitted)</param> - /// <param name="targetRange">Cell range to position chart within (e.g., 'F2:K15'). PREFERRED over left/top. When set, left/top are ignored.</param> - [ServiceAction("create-from-table")] - ChartCreateResult CreateFromTable( - IExcelBatch batch, - [RequiredParameter] string tableName, - [RequiredParameter] string sheetName, - [RequiredParameter] ChartType chartType, - double left = 0, - double top = 0, - double width = 400, - double height = 300, - string? chartName = null, - string? targetRange = null); - - /// <summary> - /// Creates a PivotChart from an existing PivotTable. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Name of the source PivotTable</param> - /// <param name="sheetName">Target worksheet name for the chart</param> - /// <param name="chartType">Type of chart to create</param> - /// <param name="left">Left position in points from worksheet edge</param> - /// <param name="top">Top position in points from worksheet edge</param> - /// <param name="width">Chart width in points</param> - /// <param name="height">Chart height in points</param> - /// <param name="chartName">Optional chart name (auto-generated if omitted)</param> - /// <param name="targetRange">Cell range to position chart within (e.g., 'F2:K15'). PREFERRED over left/top. When set, left/top are ignored.</param> - [ServiceAction("create-from-pivottable")] - ChartCreateResult CreateFromPivotTable( - IExcelBatch batch, - [RequiredParameter] string pivotTableName, - [RequiredParameter] string sheetName, - [RequiredParameter] ChartType chartType, - double left = 0, - double top = 0, - double width = 400, - double height = 300, - string? chartName = null, - string? targetRange = null); - - /// <summary> - /// Deletes a chart (Regular or PivotChart). - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="chartName">Name of the chart to delete</param> - [ServiceAction("delete")] - OperationResult Delete(IExcelBatch batch, [RequiredParameter] string chartName); - - /// <summary> - /// Moves/resizes a chart. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="chartName">Name of the chart to move</param> - /// <param name="left">New left position in points (null to keep current)</param> - /// <param name="top">New top position in points (null to keep current)</param> - /// <param name="width">New width in points (null to keep current)</param> - /// <param name="height">New height in points (null to keep current)</param> - [ServiceAction("move")] - OperationResult Move( - IExcelBatch batch, - [RequiredParameter] string chartName, - double? left = null, - double? top = null, - double? width = null, - double? height = null); - - /// <summary> - /// Fits a chart to a cell range by setting position and size to match the range bounds. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="chartName">Name of the chart to fit</param> - /// <param name="sheetName">Worksheet containing the range</param> - /// <param name="rangeAddress">Range to fit the chart to (e.g., A1:D10)</param> - [ServiceAction("fit-to-range")] - OperationResult FitToRange( - IExcelBatch batch, - [RequiredParameter] string chartName, - [RequiredParameter] string sheetName, - [RequiredParameter] string rangeAddress); -} - diff --git a/src/ExcelMcp.Core/Commands/Chart/IChartConfigCommands.cs b/src/ExcelMcp.Core/Commands/Chart/IChartConfigCommands.cs deleted file mode 100644 index f30cc987..00000000 --- a/src/ExcelMcp.Core/Commands/Chart/IChartConfigCommands.cs +++ /dev/null @@ -1,384 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Attributes; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.Chart; - -/// <summary> -/// Chart configuration - data source, series, type, title, axis labels, legend, and styling. -/// -/// SERIES MANAGEMENT: -/// - add-series: Add data series with valuesRange (required) and optional categoryRange -/// - remove-series: Remove series by 1-based index -/// - set-source-range: Replace entire chart data source -/// -/// TITLES AND LABELS: -/// - set-title: Set chart title (empty string hides title) -/// - set-axis-title: Set axis labels (Category, Value, CategorySecondary, ValueSecondary) -/// -/// CHART STYLES: 1-48 (built-in Excel styles with different color schemes) -/// -/// DATA LABELS: Show values, percentages, series/category names. -/// Positions: Center, InsideEnd, InsideBase, OutsideEnd, BestFit. -/// -/// TRENDLINES: Linear, Exponential, Logarithmic, Polynomial (order 2-6), Power, MovingAverage. -/// -/// PLACEMENT MODE: -/// - 1: Move and size with cells -/// - 2: Move but don't size with cells -/// - 3: Don't move or size with cells (free floating) -/// -/// Use chart for lifecycle operations (create, delete, move, fit-to-range). -/// </summary> -[ServiceCategory("chartconfig", "ChartConfig")] -[McpTool("chart_config", Title = "Chart Configuration", Destructive = true, Category = "analysis", - Description = "Chart configuration - data source, series, type, title, axis labels, legend, and styling. SERIES: add-series (valuesRange required), remove-series (1-based index), set-source-range. TITLES: set-title, set-axis-title (Category/Value/Secondary). AXIS: number format, scale min/max/units. LEGEND: Bottom, Corner, Top, Right, Left. STYLES: 1-48 built-in. DATA LABELS: values, percentages, positions (Center, InsideEnd, OutsideEnd, BestFit). GRIDLINES: major/minor for value/category axes. TRENDLINES: Linear, Exponential, Logarithmic, Polynomial, Power, MovingAverage. SERIES FORMAT: marker style/size/colors, invert if negative. PLACEMENT: 1=move+size with cells, 2=move only, 3=free floating. Use chart for lifecycle.")] -public interface IChartConfigCommands -{ - // === DATA SOURCE OPERATIONS === - - /// <summary> - /// Sets data source range for Regular Charts. - /// PivotCharts: Throws exception guiding to pivottable. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="chartName">Name of the chart</param> - /// <param name="sourceRange">New data source range (e.g., Sheet1!A1:D10)</param> - [ServiceAction("set-source-range")] - OperationResult SetSourceRange( - IExcelBatch batch, - [RequiredParameter] string chartName, - [RequiredParameter] string sourceRange); - - /// <summary> - /// Adds a data series to Regular Charts. - /// PivotCharts: Throws exception guiding to pivottable(action: 'add-value-field'). - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="chartName">Name of the chart</param> - /// <param name="seriesName">Display name for the series</param> - /// <param name="valuesRange">Range containing series values (e.g., B2:B10)</param> - /// <param name="categoryRange">Optional range for category labels (e.g., A2:A10)</param> - [ServiceAction("add-series")] - SeriesInfo AddSeries( - IExcelBatch batch, - [RequiredParameter] string chartName, - [RequiredParameter] string seriesName, - [RequiredParameter] string valuesRange, - string? categoryRange = null); - - /// <summary> - /// Removes a data series from Regular Charts. - /// PivotCharts: Throws exception guiding to pivottable(action: 'remove-field'). - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="chartName">Name of the chart</param> - /// <param name="seriesIndex">1-based index of the series to remove</param> - [ServiceAction("remove-series")] - OperationResult RemoveSeries( - IExcelBatch batch, - [RequiredParameter] string chartName, - [RequiredParameter] int seriesIndex); - - // === APPEARANCE OPERATIONS === - - /// <summary> - /// Changes chart type (works for both Regular and PivotCharts). - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="chartName">Name of the chart</param> - /// <param name="chartType">New chart type to apply</param> - [ServiceAction("set-chart-type")] - OperationResult SetChartType( - IExcelBatch batch, - [RequiredParameter] string chartName, - [RequiredParameter] ChartType chartType); - - /// <summary> - /// Sets chart title. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="chartName">Name of the chart</param> - /// <param name="title">Title text to display</param> - [ServiceAction("set-title")] - OperationResult SetTitle( - IExcelBatch batch, - [RequiredParameter] string chartName, - [RequiredParameter] string title); - - /// <summary> - /// Sets axis title. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="chartName">Name of the chart</param> - /// <param name="axis">Which axis to set title for (Category, Value, SeriesAxis)</param> - /// <param name="title">Axis title text</param> - [ServiceAction("set-axis-title")] - OperationResult SetAxisTitle( - IExcelBatch batch, - [RequiredParameter] string chartName, - [RequiredParameter] ChartAxisType axis, - [RequiredParameter] string title); - - /// <summary> - /// Gets axis number format for tick labels. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="chartName">Name of the chart</param> - /// <param name="axis">Which axis to get format from</param> - [ServiceAction("get-axis-number-format")] - string GetAxisNumberFormat( - IExcelBatch batch, - [RequiredParameter] string chartName, - [RequiredParameter] ChartAxisType axis); - - /// <summary> - /// Sets axis number format for tick labels. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="chartName">Name of the chart</param> - /// <param name="axis">Which axis to format</param> - /// <param name="numberFormat">Excel number format code (e.g., "$#,##0", "0.00%")</param> - [ServiceAction("set-axis-number-format")] - OperationResult SetAxisNumberFormat( - IExcelBatch batch, - [RequiredParameter] string chartName, - [RequiredParameter] ChartAxisType axis, - [RequiredParameter] string numberFormat); - - /// <summary> - /// Shows or hides chart legend. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="chartName">Name of the chart</param> - /// <param name="visible">True to show legend, false to hide</param> - /// <param name="legendPosition">Optional position for the legend</param> - [ServiceAction("show-legend")] - OperationResult ShowLegend( - IExcelBatch batch, - [RequiredParameter] string chartName, - [RequiredParameter] bool visible, - LegendPosition? legendPosition = null); - - /// <summary> - /// Applies a built-in chart style (1-48). Parameter: style_id (integer). - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="chartName">Name of the chart</param> - /// <param name="styleId">Excel chart style ID (1-48 for most chart types)</param> - [ServiceAction("set-style")] - OperationResult SetStyle( - IExcelBatch batch, - [RequiredParameter] string chartName, - [RequiredParameter] int styleId); - - /// <summary> - /// Sets chart placement mode (how chart responds when underlying cells are resized). - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="chartName">Name of the chart</param> - /// <param name="placement">Placement mode: 1=MoveAndSize, 2=Move, 3=FreeFloating</param> - [ServiceAction("set-placement")] - OperationResult SetPlacement( - IExcelBatch batch, - [RequiredParameter] string chartName, - [RequiredParameter] int placement); - - // === DATA LABELS === - - /// <summary> - /// Configures data labels for chart series. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="chartName">Name of the chart</param> - /// <param name="showValue">Show data values on labels</param> - /// <param name="showPercentage">Show percentage values. Only meaningful for pie and doughnut chart types; setting to true on other chart types has no visual effect.</param> - /// <param name="showSeriesName">Show series name on labels</param> - /// <param name="showCategoryName">Show category name on labels</param> - /// <param name="showBubbleSize">Show bubble size (bubble charts)</param> - /// <param name="separator">Separator string between label components</param> - /// <param name="labelPosition">Position of data labels relative to data points</param> - /// <param name="seriesIndex">Optional 1-based series index. Omit or 0 to apply to all series. Use 1 for first series.</param> - [ServiceAction("set-data-labels")] - OperationResult SetDataLabels( - IExcelBatch batch, - [RequiredParameter] string chartName, - bool? showValue = null, - bool? showPercentage = null, - bool? showSeriesName = null, - bool? showCategoryName = null, - bool? showBubbleSize = null, - string? separator = null, - DataLabelPosition? labelPosition = null, - int? seriesIndex = null); - - // === AXIS SCALE === - - /// <summary> - /// Gets axis scale settings. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="chartName">Name of the chart</param> - /// <param name="axis">Which axis to get scale settings from</param> - [ServiceAction("get-axis-scale")] - AxisScaleResult GetAxisScale( - IExcelBatch batch, - [RequiredParameter] string chartName, - [RequiredParameter] ChartAxisType axis); - - /// <summary> - /// Sets axis scale settings. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="chartName">Name of the chart</param> - /// <param name="axis">Which axis to configure</param> - /// <param name="minimumScale">Minimum axis value (null for auto)</param> - /// <param name="maximumScale">Maximum axis value (null for auto)</param> - /// <param name="majorUnit">Major gridline interval (null for auto)</param> - /// <param name="minorUnit">Minor gridline interval (null for auto)</param> - [ServiceAction("set-axis-scale")] - OperationResult SetAxisScale( - IExcelBatch batch, - [RequiredParameter] string chartName, - [RequiredParameter] ChartAxisType axis, - double? minimumScale = null, - double? maximumScale = null, - double? majorUnit = null, - double? minorUnit = null); - - // === GRIDLINES === - - /// <summary> - /// Gets gridlines visibility for chart axes. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="chartName">Name of the chart</param> - [ServiceAction("get-gridlines")] - GridlinesResult GetGridlines( - IExcelBatch batch, - [RequiredParameter] string chartName); - - /// <summary> - /// Configures gridlines visibility for chart axes. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="chartName">Name of the chart</param> - /// <param name="axis">Which axis gridlines to configure</param> - /// <param name="showMajor">Show major gridlines (null to keep current)</param> - /// <param name="showMinor">Show minor gridlines (null to keep current)</param> - [ServiceAction("set-gridlines")] - OperationResult SetGridlines( - IExcelBatch batch, - [RequiredParameter] string chartName, - [RequiredParameter] ChartAxisType axis, - bool? showMajor = null, - bool? showMinor = null); - - // === SERIES FORMATTING === - - /// <summary> - /// Configures series marker formatting (for line, scatter, and radar charts). - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="chartName">Name of the chart</param> - /// <param name="seriesIndex">1-based index of the series</param> - /// <param name="markerStyle">Marker shape style</param> - /// <param name="markerSize">Marker size in points (2-72)</param> - /// <param name="markerBackgroundColor">Marker fill color (#RRGGBB)</param> - /// <param name="markerForegroundColor">Marker border color (#RRGGBB)</param> - /// <param name="invertIfNegative">Invert colors for negative values</param> - [ServiceAction("set-series-format")] - OperationResult SetSeriesFormat( - IExcelBatch batch, - [RequiredParameter] string chartName, - [RequiredParameter] int seriesIndex, - MarkerStyle? markerStyle = null, - int? markerSize = null, - string? markerBackgroundColor = null, - string? markerForegroundColor = null, - bool? invertIfNegative = null); - - // === TRENDLINES === - - /// <summary> - /// Lists all trendlines on a chart series. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="chartName">Name of the chart</param> - /// <param name="seriesIndex">1-based index of the series</param> - [ServiceAction("list-trendlines")] - TrendlineListResult ListTrendlines( - IExcelBatch batch, - [RequiredParameter] string chartName, - [RequiredParameter] int seriesIndex); - - /// <summary> - /// Adds a trendline to a chart series. Parameter 'type' specifies the trendline kind (Linear, Exponential, Logarithmic, Polynomial, Power, MovingAverage). - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="chartName">Name of the chart</param> - /// <param name="seriesIndex">1-based index of the series</param> - /// <param name="trendlineType">Type of trendline (Linear, Exponential, etc.)</param> - /// <param name="order">Polynomial order (2-6, for Polynomial type)</param> - /// <param name="period">Moving average period (for MovingAverage type)</param> - /// <param name="forward">Periods to extend forward</param> - /// <param name="backward">Periods to extend backward</param> - /// <param name="intercept">Force trendline through specific Y-intercept</param> - /// <param name="displayEquation">Display trendline equation on chart</param> - /// <param name="displayRSquared">Display R-squared value on chart</param> - /// <param name="name">Custom name for the trendline</param> - [ServiceAction("add-trendline")] - TrendlineResult AddTrendline( - IExcelBatch batch, - [RequiredParameter] string chartName, - [RequiredParameter] int seriesIndex, - [RequiredParameter] TrendlineType trendlineType, - int? order = null, - int? period = null, - double? forward = null, - double? backward = null, - double? intercept = null, - bool displayEquation = false, - bool displayRSquared = false, - string? name = null); - - /// <summary> - /// Deletes a trendline from a chart series. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="chartName">Name of the chart</param> - /// <param name="seriesIndex">1-based index of the series</param> - /// <param name="trendlineIndex">1-based index of the trendline to delete</param> - [ServiceAction("delete-trendline")] - OperationResult DeleteTrendline( - IExcelBatch batch, - [RequiredParameter] string chartName, - [RequiredParameter] int seriesIndex, - [RequiredParameter] int trendlineIndex); - - /// <summary> - /// Updates trendline properties. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="chartName">Name of the chart</param> - /// <param name="seriesIndex">1-based index of the series</param> - /// <param name="trendlineIndex">1-based index of the trendline</param> - /// <param name="forward">Periods to extend forward (null to keep current)</param> - /// <param name="backward">Periods to extend backward (null to keep current)</param> - /// <param name="intercept">Force through Y-intercept (null to keep current)</param> - /// <param name="displayEquation">Display equation (null to keep current)</param> - /// <param name="displayRSquared">Display R-squared (null to keep current)</param> - /// <param name="name">Custom name (null to keep current)</param> - [ServiceAction("set-trendline")] - OperationResult SetTrendline( - IExcelBatch batch, - [RequiredParameter] string chartName, - [RequiredParameter] int seriesIndex, - [RequiredParameter] int trendlineIndex, - double? forward = null, - double? backward = null, - double? intercept = null, - bool? displayEquation = null, - bool? displayRSquared = null, - string? name = null); -} diff --git a/src/ExcelMcp.Core/Commands/Chart/IChartStrategy.cs b/src/ExcelMcp.Core/Commands/Chart/IChartStrategy.cs deleted file mode 100644 index e91852e3..00000000 --- a/src/ExcelMcp.Core/Commands/Chart/IChartStrategy.cs +++ /dev/null @@ -1,67 +0,0 @@ -namespace Sbroenne.ExcelMcp.Core.Commands.Chart; - -/// <summary> -/// Strategy interface for handling differences between Regular Charts and PivotCharts. -/// Abstracts COM API differences while providing unified API surface. -/// </summary> -public interface IChartStrategy -{ - /// <summary> - /// Determines if this strategy can handle the given chart. - /// </summary> - /// <param name="chart">Excel Chart COM object</param> - /// <returns>True if this strategy handles this chart type</returns> - bool CanHandle(dynamic chart); - - /// <summary> - /// Gets chart information (type, position, series, etc.). - /// </summary> - /// <param name="chart">Excel Chart COM object</param> - /// <param name="chartName">Name of the chart</param> - /// <param name="sheetName">Name of the worksheet</param> - /// <param name="shape">Shape or ChartObject containing the chart</param> - /// <returns>Chart information</returns> - ChartInfo GetInfo(dynamic chart, string chartName, string sheetName, dynamic shape); - - /// <summary> - /// Sets the data source range. - /// Regular Charts: Updates source range. - /// PivotCharts: Throws exception guiding to pivottable. - /// </summary> - /// <param name="chart">Excel Chart COM object</param> - /// <param name="sourceRange">New source range</param> - void SetSourceRange(dynamic chart, string sourceRange); - - /// <summary> - /// Adds a data series. - /// Regular Charts: Adds to SeriesCollection. - /// PivotCharts: Throws exception guiding to pivottable. - /// </summary> - /// <param name="chart">Excel Chart COM object</param> - /// <param name="seriesName">Name for the series</param> - /// <param name="valuesRange">Range containing Y values</param> - /// <param name="categoryRange">Optional range for X values/categories</param> - /// <returns>Series information</returns> - SeriesInfo AddSeries(dynamic chart, string seriesName, string valuesRange, string? categoryRange); - - /// <summary> - /// Removes a data series. - /// Regular Charts: Removes from SeriesCollection. - /// PivotCharts: Throws exception guiding to pivottable. - /// </summary> - /// <param name="chart">Excel Chart COM object</param> - /// <param name="seriesIndex">1-based series index</param> - void RemoveSeries(dynamic chart, int seriesIndex); - - /// <summary> - /// Gets detailed chart information including series. - /// </summary> - /// <param name="chart">Excel Chart COM object</param> - /// <param name="chartName">Name of the chart</param> - /// <param name="sheetName">Name of the worksheet</param> - /// <param name="shape">Shape or ChartObject containing the chart</param> - /// <returns>Detailed chart information</returns> - ChartInfoResult GetDetailedInfo(dynamic chart, string chartName, string sheetName, dynamic shape); -} - - diff --git a/src/ExcelMcp.Core/Commands/Chart/LegendPosition.cs b/src/ExcelMcp.Core/Commands/Chart/LegendPosition.cs deleted file mode 100644 index 60d01879..00000000 --- a/src/ExcelMcp.Core/Commands/Chart/LegendPosition.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace Sbroenne.ExcelMcp.Core.Commands.Chart; - -/// <summary> -/// Legend position constants for Excel charts. -/// Excel COM: XlLegendPosition enumeration. -/// </summary> -public enum LegendPosition -{ - /// <summary>Bottom of chart (xlLegendPositionBottom)</summary> - Bottom = -4107, - - /// <summary>Upper-right corner (xlLegendPositionCorner)</summary> - Corner = 2, - - /// <summary>Custom position (xlLegendPositionCustom)</summary> - Custom = -4161, - - /// <summary>Left side (xlLegendPositionLeft)</summary> - Left = -4131, - - /// <summary>Right side (xlLegendPositionRight)</summary> - Right = -4152, - - /// <summary>Top of chart (xlLegendPositionTop)</summary> - Top = -4160 -} - - diff --git a/src/ExcelMcp.Core/Commands/Chart/MarkerStyle.cs b/src/ExcelMcp.Core/Commands/Chart/MarkerStyle.cs deleted file mode 100644 index e785c949..00000000 --- a/src/ExcelMcp.Core/Commands/Chart/MarkerStyle.cs +++ /dev/null @@ -1,46 +0,0 @@ -namespace Sbroenne.ExcelMcp.Core.Commands.Chart; - -/// <summary> -/// Marker style constants for Excel chart series. -/// Excel COM: XlMarkerStyle enumeration. -/// </summary> -public enum MarkerStyle -{ - /// <summary>No marker (xlMarkerStyleNone)</summary> - None = -4142, - - /// <summary>Automatic marker (xlMarkerStyleAutomatic)</summary> - Automatic = -4105, - - /// <summary>Circle marker (xlMarkerStyleCircle)</summary> - Circle = 8, - - /// <summary>Dash marker (xlMarkerStyleDash)</summary> - Dash = -4115, - - /// <summary>Diamond marker (xlMarkerStyleDiamond)</summary> - Diamond = 2, - - /// <summary>Dot marker (xlMarkerStyleDot)</summary> - Dot = -4118, - - /// <summary>Picture marker (xlMarkerStylePicture)</summary> - Picture = -4147, - - /// <summary>Plus sign marker (xlMarkerStylePlus)</summary> - Plus = 9, - - /// <summary>Square marker (xlMarkerStyleSquare)</summary> - Square = 1, - - /// <summary>Star marker (xlMarkerStyleStar)</summary> - Star = 5, - - /// <summary>Triangle marker (xlMarkerStyleTriangle)</summary> - Triangle = 3, - - /// <summary>X marker (xlMarkerStyleX)</summary> - X = -4168 -} - - diff --git a/src/ExcelMcp.Core/Commands/Chart/PivotChartStrategy.cs b/src/ExcelMcp.Core/Commands/Chart/PivotChartStrategy.cs deleted file mode 100644 index 334f4e56..00000000 --- a/src/ExcelMcp.Core/Commands/Chart/PivotChartStrategy.cs +++ /dev/null @@ -1,239 +0,0 @@ -using System.Runtime.InteropServices; -using Sbroenne.ExcelMcp.ComInterop; - -namespace Sbroenne.ExcelMcp.Core.Commands.Chart; - -/// <summary> -/// Strategy for PivotCharts (created from PivotTables). -/// Handles PivotCache.CreatePivotChart(), automatic sync with PivotTable, helpful errors for series operations. -/// </summary> -public class PivotChartStrategy : IChartStrategy -{ - /// <inheritdoc /> - public bool CanHandle(dynamic chart) - { - // PivotCharts: chart.PivotLayout exists - try - { - var pivotLayout = chart.PivotLayout; - return pivotLayout != null; - } - catch (COMException) - { - return false; - } - } - - /// <inheritdoc /> - public ChartInfo GetInfo(dynamic chart, string chartName, string sheetName, dynamic shape) - { - var info = new ChartInfo - { - Name = chartName, - SheetName = sheetName, - ChartType = (ChartType)Convert.ToInt32(chart.ChartType), - IsPivotChart = true, - Left = Convert.ToDouble(shape.Left), - Top = Convert.ToDouble(shape.Top), - Width = Convert.ToDouble(shape.Width), - Height = Convert.ToDouble(shape.Height) - }; - - // Get anchor cells and placement mode - dynamic? topLeftCell = null; - dynamic? bottomRightCell = null; - try - { - topLeftCell = shape.TopLeftCell; - info.TopLeftCell = topLeftCell.Address?.ToString(); - } - catch (COMException) - { - // TopLeftCell not available - optional COM property - } - finally - { - ComUtilities.Release(ref topLeftCell!); - } - - try - { - bottomRightCell = shape.BottomRightCell; - info.BottomRightCell = bottomRightCell.Address?.ToString(); - } - catch (COMException) - { - // BottomRightCell not available - optional COM property - } - finally - { - ComUtilities.Release(ref bottomRightCell!); - } - - try - { - info.Placement = Convert.ToInt32(shape.Placement); - } - catch (COMException) - { - // Placement not available - optional COM property - } - - // Get linked PivotTable name - dynamic? pivotLayout = null; - dynamic? pivotTable = null; - try - { - pivotLayout = chart.PivotLayout; - pivotTable = pivotLayout.PivotTable; - info.LinkedPivotTable = pivotTable.Name?.ToString() ?? string.Empty; - } - finally - { - ComUtilities.Release(ref pivotTable!); - ComUtilities.Release(ref pivotLayout!); - } - - // Series count = number of value fields in PivotTable - dynamic? pivotLayout2 = null; - dynamic? pivotTable2 = null; - dynamic? dataFields = null; - try - { - pivotLayout2 = chart.PivotLayout; - pivotTable2 = pivotLayout2.PivotTable; - dataFields = pivotTable2.DataFields; - info.SeriesCount = Convert.ToInt32(dataFields.Count); - } - finally - { - ComUtilities.Release(ref dataFields!); - ComUtilities.Release(ref pivotTable2!); - ComUtilities.Release(ref pivotLayout2!); - } - - return info; - } - - /// <inheritdoc /> - public ChartInfoResult GetDetailedInfo(dynamic chart, string chartName, string sheetName, dynamic shape) - { - var info = new ChartInfoResult - { - Success = true, - Name = chartName, - SheetName = sheetName, - ChartType = (ChartType)Convert.ToInt32(chart.ChartType), - IsPivotChart = true, - Left = Convert.ToDouble(shape.Left), - Top = Convert.ToDouble(shape.Top), - Width = Convert.ToDouble(shape.Width), - Height = Convert.ToDouble(shape.Height) - }; - - // Get anchor cells and placement mode - dynamic? topLeftCell = null; - dynamic? bottomRightCell = null; - try - { - topLeftCell = shape.TopLeftCell; - info.TopLeftCell = topLeftCell.Address?.ToString(); - } - catch (COMException) - { - // TopLeftCell not available - optional COM property - } - finally - { - ComUtilities.Release(ref topLeftCell!); - } - - try - { - bottomRightCell = shape.BottomRightCell; - info.BottomRightCell = bottomRightCell.Address?.ToString(); - } - catch (COMException) - { - // BottomRightCell not available - optional COM property - } - finally - { - ComUtilities.Release(ref bottomRightCell!); - } - - try - { - info.Placement = Convert.ToInt32(shape.Placement); - } - catch (COMException) - { - // Placement not available - optional COM property - } - - // Get linked PivotTable name - dynamic? pivotLayout = null; - dynamic? pivotTable = null; - try - { - pivotLayout = chart.PivotLayout; - pivotTable = pivotLayout.PivotTable; - info.LinkedPivotTable = pivotTable.Name?.ToString() ?? string.Empty; - } - finally - { - ComUtilities.Release(ref pivotTable!); - ComUtilities.Release(ref pivotLayout!); - } - - // Get title - if (chart.HasTitle) - { - info.Title = chart.ChartTitle.Text?.ToString() ?? string.Empty; - } - - // Get legend - try - { - info.HasLegend = chart.HasLegend; - } - catch (COMException) - { - info.HasLegend = false; - } - - // PivotCharts don't expose series in the same way - data comes from PivotTable value fields - // Series list remains empty for PivotCharts - - return info; - } - - /// <inheritdoc /> - public void SetSourceRange(dynamic chart, string sourceRange) - { - throw new NotSupportedException( - "Cannot set source range for PivotChart. " + - "PivotCharts automatically sync with their PivotTable data source. " + - "Use pivottable tool to update the linked PivotTable."); - } - - /// <inheritdoc /> - public SeriesInfo AddSeries(dynamic chart, string seriesName, string valuesRange, string? categoryRange) - { - throw new NotSupportedException( - "Cannot add series directly to PivotChart. " + - "PivotCharts automatically sync with PivotTable fields. " + - "Use pivottable tool with 'add-value-field' action to add data series."); - } - - /// <inheritdoc /> - public void RemoveSeries(dynamic chart, int seriesIndex) - { - throw new NotSupportedException( - "Cannot remove series directly from PivotChart. " + - "PivotCharts automatically sync with PivotTable fields. " + - "Use pivottable tool with 'remove-field' action to remove data series."); - } -} - - diff --git a/src/ExcelMcp.Core/Commands/Chart/RegularChartStrategy.cs b/src/ExcelMcp.Core/Commands/Chart/RegularChartStrategy.cs deleted file mode 100644 index 5413e0d7..00000000 --- a/src/ExcelMcp.Core/Commands/Chart/RegularChartStrategy.cs +++ /dev/null @@ -1,311 +0,0 @@ -using System.Runtime.InteropServices; -using Sbroenne.ExcelMcp.ComInterop; - -namespace Sbroenne.ExcelMcp.Core.Commands.Chart; - -/// <summary> -/// Strategy for Regular Charts (created from ranges/tables). -/// Handles Shapes.AddChart(), SeriesCollection operations, explicit data source management. -/// </summary> -public class RegularChartStrategy : IChartStrategy -{ - /// <inheritdoc /> - public bool CanHandle(dynamic chart) - { - // Regular charts: chart.PivotLayout is null or doesn't exist - try - { - var pivotLayout = chart.PivotLayout; - return pivotLayout == null; - } - catch (COMException) - { - return true; // No PivotLayout property = Regular chart - } - } - - /// <inheritdoc /> - public ChartInfo GetInfo(dynamic chart, string chartName, string sheetName, dynamic shape) - { - var info = new ChartInfo - { - Name = chartName, - SheetName = sheetName, - ChartType = (ChartType)Convert.ToInt32(chart.ChartType), - IsPivotChart = false, - Left = Convert.ToDouble(shape.Left), - Top = Convert.ToDouble(shape.Top), - Width = Convert.ToDouble(shape.Width), - Height = Convert.ToDouble(shape.Height) - }; - - // Get anchor cells and placement mode - dynamic? topLeftCell = null; - dynamic? bottomRightCell = null; - try - { - topLeftCell = shape.TopLeftCell; - info.TopLeftCell = topLeftCell.Address?.ToString(); - } - catch (COMException) - { - // TopLeftCell not available - optional COM property - } - finally - { - ComUtilities.Release(ref topLeftCell!); - } - - try - { - bottomRightCell = shape.BottomRightCell; - info.BottomRightCell = bottomRightCell.Address?.ToString(); - } - catch (COMException) - { - // BottomRightCell not available - optional COM property - } - finally - { - ComUtilities.Release(ref bottomRightCell!); - } - - try - { - info.Placement = Convert.ToInt32(shape.Placement); - } - catch (COMException) - { - // Placement not available - optional COM property - } - - // Count series - dynamic? seriesCollection = null; - try - { - seriesCollection = chart.SeriesCollection(); - info.SeriesCount = Convert.ToInt32(seriesCollection.Count); - } - finally - { - ComUtilities.Release(ref seriesCollection!); - } - - return info; - } - - /// <inheritdoc /> - public ChartInfoResult GetDetailedInfo(dynamic chart, string chartName, string sheetName, dynamic shape) - { - var info = new ChartInfoResult - { - Success = true, - Name = chartName, - SheetName = sheetName, - ChartType = (ChartType)Convert.ToInt32(chart.ChartType), - IsPivotChart = false, - Left = Convert.ToDouble(shape.Left), - Top = Convert.ToDouble(shape.Top), - Width = Convert.ToDouble(shape.Width), - Height = Convert.ToDouble(shape.Height) - }; - - // Get anchor cells and placement mode - dynamic? topLeftCell = null; - dynamic? bottomRightCell = null; - try - { - topLeftCell = shape.TopLeftCell; - info.TopLeftCell = topLeftCell.Address?.ToString(); - } - catch (COMException) - { - // TopLeftCell not available - optional COM property - } - finally - { - ComUtilities.Release(ref topLeftCell!); - } - - try - { - bottomRightCell = shape.BottomRightCell; - info.BottomRightCell = bottomRightCell.Address?.ToString(); - } - catch (COMException) - { - // BottomRightCell not available - optional COM property - } - finally - { - ComUtilities.Release(ref bottomRightCell!); - } - - try - { - info.Placement = Convert.ToInt32(shape.Placement); - } - catch (COMException) - { - // Placement not available - optional COM property - } - - // Get title - try - { - if (chart.HasTitle) - { - info.Title = chart.ChartTitle.Text?.ToString() ?? string.Empty; - } - } - catch (COMException) - { - // No title - optional COM property, safe to ignore - } - - // Get legend - try - { - info.HasLegend = chart.HasLegend; - } - catch (COMException) - { - info.HasLegend = false; // Safe fallback for optional COM property - } - - // Get source range - try - { - dynamic sourceData = chart.ChartArea.Parent.SeriesCollection(1).Formula; - info.SourceRange = sourceData?.ToString() ?? string.Empty; - } - catch (COMException) - { - // No source range or no series - optional COM property, safe to ignore - } - - // Get series - dynamic? seriesCollection = null; - try - { - seriesCollection = chart.SeriesCollection(); - int seriesCount = Convert.ToInt32(seriesCollection.Count); - - for (int i = 1; i <= seriesCount; i++) - { - dynamic? series = null; - try - { - series = seriesCollection.Item(i); - var seriesInfo = new SeriesInfo - { - Name = series.Name?.ToString() ?? string.Empty, - ValuesRange = series.Values?.ToString() ?? string.Empty, - CategoryRange = series.XValues?.ToString() ?? string.Empty - }; - info.Series.Add(seriesInfo); - } - finally - { - if (series != null) - { - ComUtilities.Release(ref series!); - } - } - } - } - finally - { - ComUtilities.Release(ref seriesCollection!); - } - - return info; - } - - /// <inheritdoc /> - public void SetSourceRange(dynamic chart, string sourceRange) - { - dynamic? sourceRangeObj = null; - try - { - // Get workbook from chart - dynamic workbook = chart.Parent.Parent.Parent; - - // Get the range object from the address string - sourceRangeObj = workbook.Application.Range(sourceRange); - chart.SetSourceData(sourceRangeObj); - } - finally - { - if (sourceRangeObj != null) - { - ComUtilities.Release(ref sourceRangeObj!); - } - } - } - - /// <inheritdoc /> - public SeriesInfo AddSeries(dynamic chart, string seriesName, string valuesRange, string? categoryRange) - { - dynamic? seriesCollection = null; - dynamic? newSeries = null; - - try - { - seriesCollection = chart.SeriesCollection(); - newSeries = seriesCollection.NewSeries(); - newSeries.Name = seriesName; - newSeries.Values = valuesRange; - - if (!string.IsNullOrWhiteSpace(categoryRange)) - { - newSeries.XValues = categoryRange; - } - - return new SeriesInfo - { - Name = seriesName, - ValuesRange = valuesRange, - CategoryRange = categoryRange - }; - } - finally - { - if (newSeries != null) - { - ComUtilities.Release(ref newSeries!); - } - if (seriesCollection != null) - { - ComUtilities.Release(ref seriesCollection!); - } - } - } - - /// <inheritdoc /> - public void RemoveSeries(dynamic chart, int seriesIndex) - { - dynamic? seriesCollection = null; - dynamic? series = null; - - try - { - seriesCollection = chart.SeriesCollection(); - series = seriesCollection.Item(seriesIndex); - series.Delete(); - } - finally - { - if (series != null) - { - ComUtilities.Release(ref series!); - } - if (seriesCollection != null) - { - ComUtilities.Release(ref seriesCollection!); - } - } - } -} - - diff --git a/src/ExcelMcp.Core/Commands/Chart/TrendlineType.cs b/src/ExcelMcp.Core/Commands/Chart/TrendlineType.cs deleted file mode 100644 index 7b9dbb4a..00000000 --- a/src/ExcelMcp.Core/Commands/Chart/TrendlineType.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace Sbroenne.ExcelMcp.Core.Commands.Chart; - -/// <summary> -/// Types of trendlines for chart data series. -/// Maps to Excel's XlTrendlineType enum values. -/// </summary> -public enum TrendlineType -{ - /// <summary>Linear regression (y = mx + b)</summary> - Linear = -4132, - - /// <summary>Exponential (y = ce^bx)</summary> - Exponential = 5, - - /// <summary>Logarithmic (y = c ln x + b)</summary> - Logarithmic = -4133, - - /// <summary>Polynomial - requires Order parameter (2-6)</summary> - Polynomial = 3, - - /// <summary>Power (y = cx^b)</summary> - Power = 4, - - /// <summary>Moving average - requires Period parameter</summary> - MovingAverage = 6 -} - - diff --git a/src/ExcelMcp.Core/Commands/ConditionalFormat/ConditionalFormattingCommands.cs b/src/ExcelMcp.Core/Commands/ConditionalFormat/ConditionalFormattingCommands.cs deleted file mode 100644 index 507470b4..00000000 --- a/src/ExcelMcp.Core/Commands/ConditionalFormat/ConditionalFormattingCommands.cs +++ /dev/null @@ -1,241 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Implementation of conditional formatting commands -/// </summary> -public partial class ConditionalFormattingCommands : IConditionalFormattingCommands -{ - /// <inheritdoc /> - public OperationResult AddRule( - IExcelBatch batch, - string sheetName, - string rangeAddress, - string ruleType, - string? operatorType, - string? formula1, - string? formula2, - string? interiorColor = null, - string? interiorPattern = null, - string? fontColor = null, - bool? fontBold = null, - bool? fontItalic = null, - string? borderStyle = null, - string? borderColor = null) - { - return batch.Execute((ctx, ct) => - { - dynamic? sheet = null; - dynamic? range = null; - dynamic? formatConditions = null; - dynamic? formatCondition = null; - dynamic? interior = null; - dynamic? font = null; - dynamic? borders = null; - - try - { - // Get sheet - sheet = string.IsNullOrEmpty(sheetName) - ? ctx.Book.ActiveSheet - : ctx.Book.Worksheets[sheetName]; - - // Get range - range = sheet.Range[rangeAddress]; - - // Get format conditions - formatConditions = range.FormatConditions; - - // Parse rule type and operator - var xlType = ParseConditionalFormattingType(ruleType); - var xlOperator = ParseConditionalFormattingOperator(operatorType); - - // Add format condition - formatCondition = formatConditions.Add( - Type: xlType, - Operator: xlOperator, - Formula1: formula1 ?? "", - Formula2: formula2 ?? ""); - - // Apply Interior formatting - if (!string.IsNullOrEmpty(interiorColor) || !string.IsNullOrEmpty(interiorPattern)) - { - interior = formatCondition.Interior; - if (!string.IsNullOrEmpty(interiorColor)) - interior.Color = FormattingHelpers.ParseColor(interiorColor); - if (!string.IsNullOrEmpty(interiorPattern)) - interior.Pattern = ParseInteriorPattern(interiorPattern); - } - - // Apply Font formatting - if (!string.IsNullOrEmpty(fontColor) || fontBold.HasValue || fontItalic.HasValue) - { - font = formatCondition.Font; - if (!string.IsNullOrEmpty(fontColor)) - font.Color = FormattingHelpers.ParseColor(fontColor); - if (fontBold.HasValue) - font.Bold = fontBold.Value; - if (fontItalic.HasValue) - font.Italic = fontItalic.Value; - } - - // Apply Border formatting - if (!string.IsNullOrEmpty(borderStyle) || !string.IsNullOrEmpty(borderColor)) - { - borders = formatCondition.Borders; - if (!string.IsNullOrEmpty(borderStyle)) - { - var xlBorderStyle = FormattingHelpers.ParseBorderStyle(borderStyle); - // Apply to all four borders - borders.Item(7).LineStyle = xlBorderStyle; // xlEdgeLeft - borders.Item(8).LineStyle = xlBorderStyle; // xlEdgeTop - borders.Item(9).LineStyle = xlBorderStyle; // xlEdgeBottom - borders.Item(10).LineStyle = xlBorderStyle; // xlEdgeRight - } - if (!string.IsNullOrEmpty(borderColor)) - { - var color = FormattingHelpers.ParseColor(borderColor); - borders.Item(7).Color = color; // xlEdgeLeft - borders.Item(8).Color = color; // xlEdgeTop - borders.Item(9).Color = color; // xlEdgeBottom - borders.Item(10).Color = color; // xlEdgeRight - } - } - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; // Dummy return for batch.Execute - } - finally - { - ComUtilities.Release(ref borders!); - ComUtilities.Release(ref font!); - ComUtilities.Release(ref interior!); - ComUtilities.Release(ref formatCondition!); - ComUtilities.Release(ref formatConditions!); - ComUtilities.Release(ref range!); - ComUtilities.Release(ref sheet!); - } - }); - } - - /// <inheritdoc /> - public OperationResult ClearRules( - IExcelBatch batch, - string sheetName, - string rangeAddress) - { - return batch.Execute((ctx, ct) => - { - dynamic? sheet = null; - dynamic? range = null; - dynamic? formatConditions = null; - - try - { - // Get sheet - sheet = string.IsNullOrEmpty(sheetName) - ? ctx.Book.ActiveSheet - : ctx.Book.Worksheets[sheetName]; - - // Get range - range = sheet.Range[rangeAddress]; - - // Get and delete format conditions - formatConditions = range.FormatConditions; - formatConditions.Delete(); - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; // Dummy return for batch.Execute - } - finally - { - ComUtilities.Release(ref formatConditions!); - ComUtilities.Release(ref range!); - ComUtilities.Release(ref sheet!); - } - }); - } - - // === HELPER METHODS === - - private static int ParseConditionalFormattingType(string type) - { - return type.ToLowerInvariant() switch - { - "cellvalue" => 1, // xlCellValue - "cell-value" => 1, // xlCellValue (kebab-case alias) - "expression" => 2, // xlExpression - "colorscale" => 3, // xlColorScale - "color-scale" => 3, // xlColorScale (kebab-case alias) - "databar" => 4, // xlDatabar - "data-bar" => 4, // xlDatabar (kebab-case alias) - "top10" => 5, // xlTop10 - "iconset" => 6, // xlIconSet - "icon-set" => 6, // xlIconSet (kebab-case alias) - "uniquevalues" => 8, // xlUniqueValues - "unique-values" => 8, // xlUniqueValues (kebab-case alias) - "blankscondition" => 10, // xlBlanksCondition - "blanks-condition" => 10, // xlBlanksCondition (kebab-case alias) - "timeperiod" => 11, // xlTimePeriod - "time-period" => 11, // xlTimePeriod (kebab-case alias) - "aboveaverage" => 12, // xlAboveAverageCondition - "above-average" => 12, // xlAboveAverageCondition (kebab-case alias) - _ => throw new ArgumentException( - $"Invalid conditional formatting type: '{type}'. " + - "Valid values: cellValue, expression, colorScale, dataBar, top10, iconSet, uniqueValues, blanksCondition, timePeriod, aboveAverage") - }; - } - - private static int ParseConditionalFormattingOperator(string? operatorType) - { - if (string.IsNullOrEmpty(operatorType)) - return 3; // xlEqual (default) - - return operatorType.ToLowerInvariant() switch - { - "between" => 1, // xlBetween - "notbetween" => 2, // xlNotBetween - "not-between" => 2, // xlNotBetween (kebab-case alias) - "equal" => 3, // xlEqual - "notequal" => 4, // xlNotEqual - "not-equal" => 4, // xlNotEqual (kebab-case alias) - "greater" => 5, // xlGreater - "greaterthan" => 5, // xlGreater (alias) - "less" => 6, // xlLess - "lessthan" => 6, // xlLess (alias) - "greaterequal" => 7, // xlGreaterEqual - "greater-equal" => 7, // xlGreaterEqual (kebab-case alias) - "greaterthanorequal" => 7, // xlGreaterEqual (alias) - ">=" => 7, // xlGreaterEqual (symbol alias) - "lessequal" => 8, // xlLessEqual - "less-equal" => 8, // xlLessEqual (kebab-case alias) - "lessthanorequal" => 8, // xlLessEqual (alias) - "<=" => 8, // xlLessEqual (symbol alias) - "=" => 3, // xlEqual (symbol alias) - "<>" => 4, // xlNotEqual (symbol alias) - ">" => 5, // xlGreater (symbol alias) - "<" => 6, // xlLess (symbol alias) - _ => throw new ArgumentException($"Unknown operator type: '{operatorType}'. Valid values: between, notBetween, equal, notEqual, greater, less, greaterEqual, lessEqual") - }; - } - - private static int ParseInteriorPattern(string pattern) - { - if (int.TryParse(pattern, out var patternValue)) - return patternValue; - - return pattern.ToLowerInvariant() switch - { - "none" => -4142, // xlPatternNone - "solid" => 1, // xlPatternSolid - "gray50" => 9, // xlPatternGray50 - "gray75" => 10, // xlPatternGray75 - "gray25" => 11, // xlPatternGray25 - _ => throw new ArgumentException($"Unknown interior pattern: {pattern}. Use pattern constant or: none, solid, gray50, gray75, gray25") - }; - } -} - - - diff --git a/src/ExcelMcp.Core/Commands/ConditionalFormat/IConditionalFormattingCommands.cs b/src/ExcelMcp.Core/Commands/ConditionalFormat/IConditionalFormattingCommands.cs deleted file mode 100644 index 49876278..00000000 --- a/src/ExcelMcp.Core/Commands/ConditionalFormat/IConditionalFormattingCommands.cs +++ /dev/null @@ -1,73 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Attributes; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Conditional formatting - visual rules based on cell values. -/// TYPES: cellValue (requires operatorType+formula1), expression (formula only). Both camelCase and kebab-case accepted. -/// FORMAT: interiorColor/fontColor as #RRGGBB, fontBold/Italic, borderStyle/Color. -/// -/// OPERATORS: equal, notEqual, greater, less, greaterEqual, lessEqual, between, notBetween. -/// For 'between' and 'notBetween', both formula1 and formula2 are required. -/// </summary> -[ServiceCategory("conditionalformat", "ConditionalFormat")] -[McpTool("conditionalformat", Title = "Conditional Formatting", Destructive = true, Category = "structure", - Description = "Conditional formatting - visual rules based on cell values. TYPES: cellValue (accepts both camelCase and kebab-case, e.g. cell-value), expression. For cellValue: requires operatorType + formula1. FORMAT: interiorColor/fontColor as #RRGGBB hex, fontBold/fontItalic booleans, borderStyle/borderColor.")] -public interface IConditionalFormattingCommands -{ - /// <summary> - /// Adds conditional formatting rule to range with full format control - /// Excel COM: Range.FormatConditions.Add(), FormatCondition.Interior/Font/Borders - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Sheet name (empty for active sheet)</param> - /// <param name="rangeAddress">Range address (A1 notation or named range)</param> - /// <param name="ruleType">Rule type: cellValue (or cell-value), expression, colorScale, dataBar, top10, iconSet, uniqueValues, blanksCondition, timePeriod, aboveAverage. Both camelCase and kebab-case accepted.</param> - /// <param name="operatorType">XlFormatConditionOperator: equal, notEqual, greater, less, greaterEqual, lessEqual, between, notBetween</param> - /// <param name="formula1">First formula/value for condition</param> - /// <param name="formula2">Second formula/value (for between/notBetween)</param> - /// <param name="interiorColor">Fill color (#RRGGBB or color index)</param> - /// <param name="interiorPattern">Interior pattern (1=Solid, -4142=None, 9=Gray50, etc.)</param> - /// <param name="fontColor">Font color (#RRGGBB or color index)</param> - /// <param name="fontBold">Bold font</param> - /// <param name="fontItalic">Italic font</param> - /// <param name="borderStyle">Border style: none, continuous, dash, dot, etc.</param> - /// <param name="borderColor">Border color (#RRGGBB or color index)</param> - /// <exception cref="InvalidOperationException">Sheet or range not found</exception> - /// <exception cref="ArgumentException">Invalid rule type, operator, color, or format value</exception> - [ServiceAction("add-rule")] - OperationResult AddRule( - IExcelBatch batch, - [RequiredParameter, FromString("sheetName")] string sheetName, - [RequiredParameter, FromString("rangeAddress")] string rangeAddress, - [RequiredParameter, FromString("ruleType")] string ruleType, - [FromString("operatorType")] string? operatorType, - [FromString("formula1")] string? formula1, - [FromString("formula2")] string? formula2, - [FromString("interiorColor")] string? interiorColor = null, - [FromString("interiorPattern")] string? interiorPattern = null, - [FromString("fontColor")] string? fontColor = null, - [FromString("fontBold")] bool? fontBold = null, - [FromString("fontItalic")] bool? fontItalic = null, - [FromString("borderStyle")] string? borderStyle = null, - [FromString("borderColor")] string? borderColor = null); - - /// <summary> - /// Removes all conditional formatting from range - /// Excel COM: Range.FormatConditions.Delete() - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Target worksheet name</param> - /// <param name="rangeAddress">Range address to clear rules from (e.g., A1:D10)</param> - /// <exception cref="InvalidOperationException">Sheet or range not found</exception> - [ServiceAction("clear-rules")] - OperationResult ClearRules( - IExcelBatch batch, - [RequiredParameter, FromString("sheetName")] string sheetName, - [RequiredParameter, FromString("rangeAddress")] string rangeAddress); -} - - - diff --git a/src/ExcelMcp.Core/Commands/Connection/ConnectionCommands.Lifecycle.cs b/src/ExcelMcp.Core/Commands/Connection/ConnectionCommands.Lifecycle.cs deleted file mode 100644 index 513e28f9..00000000 --- a/src/ExcelMcp.Core/Commands/Connection/ConnectionCommands.Lifecycle.cs +++ /dev/null @@ -1,390 +0,0 @@ -using System.Runtime.InteropServices; -using System.Text.Json; -using Microsoft.CSharp.RuntimeBinder; -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Connections; -using Sbroenne.ExcelMcp.Core.Models; -using Sbroenne.ExcelMcp.Core.PowerQuery; -using Excel = Microsoft.Office.Interop.Excel; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Connection lifecycle operations (List, View, Import, Export, Update, Delete) -/// </summary> -public partial class ConnectionCommands -{ - private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true }; - - /// <summary> - /// Lists all connections in a workbook - /// </summary> - public ConnectionListResult List(IExcelBatch batch) - { - var result = new ConnectionListResult { FilePath = batch.WorkbookPath }; - - return batch.Execute((ctx, ct) => - { - dynamic? connections = null; - - try - { - connections = ctx.Book.Connections; - - for (int i = 1; i <= connections.Count; i++) - { - dynamic? conn = null; - try - { - conn = connections.Item(i); - - var connInfo = new ConnectionInfo - { - Name = conn.Name?.ToString() ?? "", - Description = conn.Description?.ToString() ?? "", - Type = ConnectionHelpers.GetConnectionTypeName(conn.Type), - IsPowerQuery = PowerQueryHelpers.IsPowerQueryConnection(conn), - BackgroundQuery = GetBackgroundQuerySetting(conn), - RefreshOnFileOpen = GetRefreshOnFileOpenSetting(conn), - LastRefresh = GetLastRefreshDate(conn) - }; - - result.Connections.Add(connInfo); - } - catch (System.Runtime.InteropServices.COMException) - { - // Skip connections that have COM access issues - continue; - } - finally - { - ComUtilities.Release(ref conn); - } - } - - result.Success = true; - return result; - } - finally - { - ComUtilities.Release(ref connections); - } - }); - } - - /// <summary> - /// Views detailed connection information - /// </summary> - public ConnectionViewResult View(IExcelBatch batch, string connectionName) - { - var result = new ConnectionViewResult - { - FilePath = batch.WorkbookPath, - ConnectionName = connectionName - }; - - return batch.Execute((ctx, ct) => - { - Excel.WorkbookConnection? conn = ComUtilities.FindConnection(ctx.Book, connectionName); - - if (conn == null) - { - throw new InvalidOperationException($"Connection '{connectionName}' not found."); - } - - result.Type = ConnectionHelpers.GetConnectionTypeName((int)conn.Type); - result.IsPowerQuery = PowerQueryHelpers.IsPowerQueryConnection(conn); - - // Get connection string (raw for LLM usage - sanitization removed) - string? rawConnectionString = GetConnectionString(conn); - result.ConnectionString = rawConnectionString ?? ""; - - // Get command text and type - result.CommandText = GetCommandText(conn); - result.CommandType = GetCommandType(conn); - - // Build comprehensive JSON definition - var definition = new - { - Name = connectionName, - Type = result.Type, - Description = conn.Description?.ToString() ?? "", - IsPowerQuery = result.IsPowerQuery, - ConnectionString = result.ConnectionString, - CommandText = result.CommandText, - CommandType = result.CommandType, - Properties = GetConnectionProperties(conn) - }; - - result.DefinitionJson = JsonSerializer.Serialize(definition, s_jsonOptions); - - result.Success = true; - return result; - }); - } - - /// <summary> - /// Creates a new connection in the workbook - /// </summary> - public OperationResult Create(IExcelBatch batch, string connectionName, - string connectionString, string? commandText = null, string? description = null) - { - return batch.Execute((ctx, ct) => - { - // Create connection definition - var definition = new ConnectionDefinition - { - Name = connectionName, - Description = description ?? "", - ConnectionString = connectionString, - CommandText = commandText ?? "", - CommandType = string.IsNullOrWhiteSpace(commandText) ? null : "SQL", - SavePassword = false // Default to secure setting - }; - - // Create the connection using existing helper method - CreateConnection(ctx.Book, connectionName, definition); - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - }); - } - - /// <summary> - /// Refreshes connection data - /// </summary> - public OperationResult Refresh(IExcelBatch batch, string connectionName) - { - return Refresh(batch, connectionName, timeout: null); - } - - /// <summary> - /// Refreshes connection data with timeout - /// </summary> - public OperationResult Refresh(IExcelBatch batch, string connectionName, TimeSpan? timeout) - { - var effectiveTimeout = timeout ?? ComInteropConstants.DataOperationTimeout; - using var timeoutCts = new CancellationTokenSource(effectiveTimeout); - - return batch.Execute((ctx, ct) => - { - Excel.WorkbookConnection? conn = ComUtilities.FindConnection(ctx.Book, connectionName); - - if (conn == null) - { - throw new InvalidOperationException($"Connection '{connectionName}' not found."); - } - - // Check if this is a Power Query connection (handle separately) - if (PowerQueryHelpers.IsPowerQueryConnection(conn)) - { - // Check if this is an orphaned Power Query connection - if (PowerQueryHelpers.IsOrphanedPowerQueryConnection(ctx.Book, conn)) - { - throw new InvalidOperationException($"Connection '{connectionName}' is an orphaned Power Query connection with no corresponding query. Use connection 'delete' to remove it."); - } - throw new InvalidOperationException($"Connection '{connectionName}' is a Power Query connection. Use powerquery 'refresh' instead."); - } - - RefreshWorkbookConnection(conn, ct); - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - }, timeoutCts.Token); // Extended timeout (default 5 minutes) for slow data sources - } - - private static void RefreshWorkbookConnection(Excel.WorkbookConnection connection, CancellationToken cancellationToken) - { - dynamic? subConnection = null; - bool originalBackgroundQuery = false; - bool canRestoreBackgroundQuery = false; - bool supportsRefreshing = false; - - try - { - try - { - subConnection = GetTypedSubConnection(connection); - if (subConnection != null) - { - originalBackgroundQuery = subConnection.BackgroundQuery; - canRestoreBackgroundQuery = true; - - // CRITICAL: Force BackgroundQuery = false to ensure synchronous refresh. - // - // With BackgroundQuery = true (async), connection.Refresh() returns immediately - // while Excel processes the query in a background thread. We then poll - // connection.Refreshing with Thread.Sleep(5000). On STA threads with the - // OleMessageFilter registered, COM events from Excel during the background refresh - // cause Thread.Sleep to return via MsgWaitForMultipleObjectsEx — turning the - // polling loop into a 100% CPU spin for the entire duration of the refresh. - // - // With BackgroundQuery = false (synchronous), connection.Refresh() blocks the - // STA thread until done. connection.Refreshing is false when it returns, so - // WaitForConnectionRefreshCompletion exits immediately with zero CPU overhead. - subConnection.BackgroundQuery = false; - } - } - catch (COMException) - { - // Provider doesn't support BackgroundQuery — proceed with default behavior. - } - catch (RuntimeBinderException) - { - // Sub-connection doesn't expose BackgroundQuery — proceed with default behavior. - } - - // Enter long operation mode: MessagePending returns WAITDEFPROCESS to dispatch - // to HandleInComingCall, which rejects with SERVERCALL_RETRYLATER. - // This triggers the caller's RetryRejectedCall backoff instead of either: - // - WAITNOPROCESS rejection storm (88% CPU) or - // - WAITDEFPROCESS + EnsureScanDefinedEvents spin (97% CPU) - OleMessageFilter.EnterLongOperation(); - try - { - connection.Refresh(); - } - finally - { - OleMessageFilter.ExitLongOperation(); - } - - try - { - // PIA gap: WorkbookConnection.Refreshing not in Microsoft.Office.Interop.Excel v16 PIA - _ = ((dynamic)connection).Refreshing; - supportsRefreshing = true; - } - catch (COMException) - { - supportsRefreshing = false; - } - catch (RuntimeBinderException) - { - supportsRefreshing = false; - } - - if (supportsRefreshing) - { - WaitForConnectionRefreshCompletion( - () => - { - try - { - // PIA gap: WorkbookConnection.Refreshing not in Microsoft.Office.Interop.Excel v16 PIA - return ((dynamic)connection).Refreshing; - } - catch (COMException) - { - return false; - } - catch (RuntimeBinderException) - { - return false; - } - }, - () => - { - try - { - // PIA gap: WorkbookConnection.CancelRefresh not in Microsoft.Office.Interop.Excel v16 PIA - ((dynamic)connection).CancelRefresh(); - } - catch (COMException) - { - // Provider does not support cancellation. - } - catch (RuntimeBinderException) - { - // Provider does not expose cancellation. - } - }, - cancellationToken); - } - } - finally - { - if (canRestoreBackgroundQuery && subConnection != null) - { - try - { - subConnection.BackgroundQuery = originalBackgroundQuery; - } - catch (COMException) - { - // Ignore inability to restore provider-specific setting. - } - } - - ComUtilities.Release(ref subConnection); - } - } - - private static void WaitForConnectionRefreshCompletion( - Func<bool> isRefreshing, - Action cancelRefresh, - CancellationToken cancellationToken) - { - // CRITICAL: Rate-limit the isRefreshing() COM call to every 5000ms of *real* elapsed time. - // See WaitForRefreshCompletion in PowerQueryCommands.Helpers.cs for full explanation. - const int CheckIntervalMs = 5000; - var sw = System.Diagnostics.Stopwatch.StartNew(); - try - { - if (!isRefreshing()) - return; - - while (true) - { - cancellationToken.ThrowIfCancellationRequested(); - // Sleep without pumping the STA COM queue. See WaitForRefreshCompletion for details. - ComUtilities.KernelSleep(CheckIntervalMs); - if (sw.Elapsed.TotalMilliseconds < CheckIntervalMs) - continue; - sw.Restart(); - if (!isRefreshing()) - break; - } - } - catch (OperationCanceledException) - { - cancelRefresh(); - throw; - } - } - - /// <summary> - /// Deletes a connection - /// </summary> - public OperationResult Delete(IExcelBatch batch, string connectionName) - { - return batch.Execute((ctx, ct) => - { - Excel.WorkbookConnection? conn = ComUtilities.FindConnection(ctx.Book, connectionName); - - if (conn == null) - { - throw new InvalidOperationException($"Connection '{connectionName}' not found."); - } - - // Check if this is a Power Query connection - if (PowerQueryHelpers.IsPowerQueryConnection(conn)) - { - // Check if this is an orphaned Power Query connection (no corresponding query exists) - // Orphaned connections can be safely deleted via the connection API - if (!PowerQueryHelpers.IsOrphanedPowerQueryConnection(ctx.Book, conn)) - { - throw new InvalidOperationException($"Connection '{connectionName}' is a Power Query connection. Use powerquery with action 'Delete' instead."); - } - // Orphaned connection - allow deletion to proceed - } - - // Remove associated QueryTables first - PowerQueryHelpers.RemoveQueryTables(ctx.Book, connectionName); - - // Delete the connection - conn.Delete(); - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - }); - } -} - - - diff --git a/src/ExcelMcp.Core/Commands/Connection/ConnectionCommands.Operations.cs b/src/ExcelMcp.Core/Commands/Connection/ConnectionCommands.Operations.cs deleted file mode 100644 index 24a57161..00000000 --- a/src/ExcelMcp.Core/Commands/Connection/ConnectionCommands.Operations.cs +++ /dev/null @@ -1,132 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; -using Sbroenne.ExcelMcp.Core.PowerQuery; -using Excel = Microsoft.Office.Interop.Excel; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Connection operations (LoadTo, Test) -/// </summary> -public partial class ConnectionCommands -{ - /// <summary> - /// Loads connection data to a worksheet - /// </summary> - public OperationResult LoadTo(IExcelBatch batch, string connectionName, string sheetName) - { - using var timeoutCts = new CancellationTokenSource(ComInteropConstants.DataOperationTimeout); - - return batch.Execute((ctx, ct) => - { - Excel.WorkbookConnection? conn = null; - dynamic? sheets = null; - dynamic? targetSheet = null; - - try - { - conn = ComUtilities.FindConnection(ctx.Book, connectionName); - - if (conn == null) - { - throw new InvalidOperationException($"Connection '{connectionName}' not found."); - } - - // Check if this is a Power Query connection - if (PowerQueryHelpers.IsPowerQueryConnection(conn)) - { - throw new InvalidOperationException($"Connection '{connectionName}' is a Power Query connection. Use 'pq-loadto' command instead."); - } - - // Find or create target sheet - sheets = ctx.Book.Worksheets; - - for (int i = 1; i <= sheets.Count; i++) - { - dynamic? sheet = null; - try - { - sheet = sheets.Item(i); - if (sheet.Name.ToString().Equals(sheetName, StringComparison.OrdinalIgnoreCase)) - { - targetSheet = sheet; - sheet = null; // Don't release in finally since we're keeping reference - break; - } - } - finally - { - ComUtilities.Release(ref sheet); - } - } - - if (targetSheet == null) - { - targetSheet = sheets.Add(); - targetSheet.Name = sheetName; - } - - // Remove existing QueryTables first - PowerQueryHelpers.RemoveQueryTables(ctx.Book, connectionName); - - // Create QueryTable to load data - var options = new PowerQueryHelpers.QueryTableOptions - { - Name = connectionName, - RefreshImmediately = true - }; - - CreateQueryTableForConnection(targetSheet, conn, options); - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref targetSheet); - ComUtilities.Release(ref sheets); - ComUtilities.Release(ref conn); - } - }, timeoutCts.Token); - } - - /// <summary> - /// Gets connection properties - /// </summary> - - public OperationResult Test(IExcelBatch batch, string connectionName) - { - return batch.Execute((ctx, ct) => - { - Excel.WorkbookConnection? conn = ComUtilities.FindConnection(ctx.Book, connectionName); - - if (conn == null) - { - throw new InvalidOperationException($"Connection '{connectionName}' not found."); - } - - // Get connection type - int connType = (int)conn.Type; - - // For Text (4) and Web (5) connections, connection string might not be accessible - // until a QueryTable is created. Just verify the connection object exists. - if (connType is 4 or 5) - { - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - - // For other connection types (OLEDB, ODBC), validate connection string - string? connectionString = GetConnectionString(conn); - - if (string.IsNullOrWhiteSpace(connectionString)) - { - throw new InvalidOperationException("Connection has no connection string configured"); - } - - // Connection exists and is accessible - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - }); - } -} - - - diff --git a/src/ExcelMcp.Core/Commands/Connection/ConnectionCommands.Properties.cs b/src/ExcelMcp.Core/Commands/Connection/ConnectionCommands.Properties.cs deleted file mode 100644 index aec61f9a..00000000 --- a/src/ExcelMcp.Core/Commands/Connection/ConnectionCommands.Properties.cs +++ /dev/null @@ -1,95 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; -using Sbroenne.ExcelMcp.Core.PowerQuery; -using Excel = Microsoft.Office.Interop.Excel; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Connection property management (Get/Set properties) -/// </summary> -public partial class ConnectionCommands -{ - /// <inheritdoc /> - public ConnectionPropertiesResult GetProperties(IExcelBatch batch, string connectionName) - { - var result = new ConnectionPropertiesResult - { - FilePath = batch.WorkbookPath, - ConnectionName = connectionName - }; - - return batch.Execute((ctx, ct) => - { - Excel.WorkbookConnection? conn = ComUtilities.FindConnection(ctx.Book, connectionName); - - if (conn == null) - { - throw new InvalidOperationException($"Connection '{connectionName}' not found."); - } - - result.BackgroundQuery = GetBackgroundQuerySetting(conn); - result.RefreshOnFileOpen = GetRefreshOnFileOpenSetting(conn); - result.SavePassword = GetSavePasswordSetting(conn); - result.RefreshPeriod = GetRefreshPeriod(conn); - - result.Success = true; - return result; - }); - } - - /// <inheritdoc /> - public OperationResult SetProperties(IExcelBatch batch, string connectionName, - string? connectionString = null, string? commandText = null, string? description = null, - bool? backgroundQuery = null, bool? refreshOnFileOpen = null, - bool? savePassword = null, int? refreshPeriod = null) - { - return batch.Execute((ctx, ct) => - { - Excel.WorkbookConnection? conn = ComUtilities.FindConnection(ctx.Book, connectionName); - - if (conn == null) - { - throw new InvalidOperationException($"Connection '{connectionName}' not found."); - } - - // Check if this is a Power Query connection - if (PowerQueryHelpers.IsPowerQueryConnection(conn)) - { - throw new InvalidOperationException($"Connection '{connectionName}' is a Power Query connection. Power Query properties cannot be modified directly."); - } - - // Build connection definition with specified properties - var definition = new ConnectionDefinition - { - ConnectionString = connectionString, - CommandText = commandText, - Description = description, - BackgroundQuery = backgroundQuery, - RefreshOnFileOpen = refreshOnFileOpen, - SavePassword = savePassword, - RefreshPeriod = refreshPeriod - }; - - // Use UpdateConnectionProperties to apply all changes - try - { - UpdateConnectionProperties(conn, definition); - } - catch (InvalidOperationException ex) when (ex.Message.Contains("0x800A03EC") && !string.IsNullOrWhiteSpace(connectionString)) - { - // Excel blocks connection string updates for ODC-imported connections (security feature) - throw new InvalidOperationException( - $"Cannot update connection string for connection '{connectionName}'. " + - "Excel blocks connection string changes for ODC-imported connections (security restriction). " + - "To change the data source, delete this connection and import a new ODC file, or create a new connection with connection create action.", - ex); - } - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - }); - } -} - - - diff --git a/src/ExcelMcp.Core/Commands/Connection/ConnectionCommands.cs b/src/ExcelMcp.Core/Commands/Connection/ConnectionCommands.cs deleted file mode 100644 index 0dff265c..00000000 --- a/src/ExcelMcp.Core/Commands/Connection/ConnectionCommands.cs +++ /dev/null @@ -1,524 +0,0 @@ -using System.Globalization; -using System.Runtime.InteropServices; -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.Core.PowerQuery; - - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Connection management commands - Core data layer (no console output) -/// Provides CRUD operations for Excel data connections (OLEDB, ODBC, Text, Web, etc.) -/// </summary> -public partial class ConnectionCommands : IConnectionCommands -{ - #region Helper Methods - - /// <summary> - /// Returns the typed sub-connection (OLEDBConnection, ODBCConnection, TextConnection, or WebConnection) - /// based on the connection type. For types 3/4, tries TextConnection first then WebConnection - /// because Excel may report CSV files as either type. - /// </summary> - private static dynamic? GetTypedSubConnection(dynamic conn) - { - int connType = conn.Type; - - if (connType == 1) return conn.OLEDBConnection; - if (connType == 2) return conn.ODBCConnection; - if (connType is 3 or 4) - { - try { return conn.TextConnection; } - catch (COMException) - { - try { return conn.WebConnection; } - catch (COMException) { return null; } - } - } - - return null; - } - - private static bool GetBackgroundQuerySetting(dynamic conn) - { - try { return GetTypedSubConnection(conn)?.BackgroundQuery ?? false; } - catch (COMException) { return false; } - } - - private static bool GetRefreshOnFileOpenSetting(dynamic conn) - { - try { return GetTypedSubConnection(conn)?.RefreshOnFileOpen ?? false; } - catch (COMException) { return false; } - } - - private static bool GetSavePasswordSetting(dynamic conn) - { - try { return GetTypedSubConnection(conn)?.SavePassword ?? false; } - catch (COMException) { return false; } - } - - private static int GetRefreshPeriod(dynamic conn) - { - try { return GetTypedSubConnection(conn)?.RefreshPeriod ?? 0; } - catch (COMException) { return 0; } - } - - private static DateTime? GetLastRefreshDate(dynamic conn) - { - try - { - // RefreshDate is only available on OLEDB and ODBC connections - int connType = conn.Type; - if (connType is not (1 or 2)) return null; - return GetTypedSubConnection(conn)?.RefreshDate; - } - catch (COMException) { return null; } - } - - private static string? GetConnectionString(dynamic conn) - { - try - { - int connType = conn.Type; - string? connectionString = null; - - if (connType == 1) // OLEDB - { - connectionString = conn.OLEDBConnection?.Connection?.ToString(); - } - else if (connType == 2) // ODBC - { - connectionString = conn.ODBCConnection?.Connection?.ToString(); - } - else if (connType == 4) // TEXT (xlConnectionTypeTEXT) - { - dynamic textConn = conn.TextConnection; - if (textConn != null) - { - connectionString = textConn.Connection?.ToString(); - } - } - else if (connType == 5) // WEB (xlConnectionTypeWEB) - { - dynamic webConn = conn.WebConnection; - if (webConn != null) - { - connectionString = webConn.Connection?.ToString(); - } - } - - // Fallback to root ConnectionString property - if (string.IsNullOrWhiteSpace(connectionString)) - { - try - { - connectionString = conn.ConnectionString?.ToString(); - } - catch (COMException) - { - // Property not available - } - } - - return connectionString; - } - catch (COMException) - { - // Property not available - } - - return null; - } - - private static string? GetCommandText(dynamic conn) - { - try { return GetTypedSubConnection(conn)?.CommandText?.ToString(); } - catch (COMException) { return null; } - } - - private static string? GetCommandType(dynamic conn) - { - try - { - // CommandType is only available on OLEDB and ODBC connections - int connType = conn.Type; - if (connType is not (1 or 2)) return null; - - int? cmdType = GetTypedSubConnection(conn)?.CommandType; - if (!cmdType.HasValue) return "Unknown(null)"; - return cmdType.Value switch - { - 1 => "Cube", - 2 => "SQL", - 3 => "Table", - 4 => "Default", - 5 => "List", - _ => $"Unknown({cmdType.Value.ToString(CultureInfo.InvariantCulture)})" - }; - } - catch (COMException) { return null; } - } - - private static object GetConnectionProperties(dynamic conn) - { - return new - { - BackgroundQuery = GetBackgroundQuerySetting(conn), - RefreshOnFileOpen = GetRefreshOnFileOpenSetting(conn), - SavePassword = GetSavePasswordSetting(conn), - RefreshPeriod = GetRefreshPeriod(conn), - LastRefresh = GetLastRefreshDate(conn) - }; - } - - private static void CreateConnection(dynamic workbook, string connectionName, ConnectionDefinition definition) - { - // Validate required fields - if (string.IsNullOrWhiteSpace(definition.ConnectionString)) - { - throw new InvalidOperationException("ConnectionString is required to create a connection."); - } - - // Reject TEXT/WEB connection strings (legacy, use Power Query or ODC import instead) - string connStr = definition.ConnectionString.Trim(); - if (connStr.StartsWith("TEXT;", StringComparison.OrdinalIgnoreCase) || - connStr.StartsWith("URL;", StringComparison.OrdinalIgnoreCase)) - { - throw new NotSupportedException( - "TEXT and WEB connections are no longer supported via create action. " + - "Use powerquery tool for file/web imports, or create an ODC file and use import-odc action."); - } - - dynamic? connections = null; - dynamic? newConnection = null; - - try - { - connections = workbook.Connections; - - object commandTypeArgument = DetermineCommandType(definition); - - // Use Add2() method (Add() is deprecated per Microsoft docs) - // https://learn.microsoft.com/en-us/dotnet/api/microsoft.office.interop.excel.connections.add2 - newConnection = connections.Add2( - Name: connectionName, - Description: definition.Description ?? "", - ConnectionString: definition.ConnectionString, - CommandText: definition.CommandText ?? "", - lCmdtype: commandTypeArgument, - CreateModelConnection: false, // Don't create PowerPivot model connection - ImportRelationships: false // Don't import relationships - ); - - // Connection created successfully - let exceptions propagate naturally - } - finally - { - ComUtilities.Release(ref newConnection); - ComUtilities.Release(ref connections); - } - } - - private static void UpdateConnectionProperties(dynamic conn, ConnectionDefinition definition) - { - try - { - // Update description - if (!string.IsNullOrWhiteSpace(definition.Description)) - { - conn.Description = definition.Description; - } - - int connType = conn.Type; - - if (connType == 1) // OLEDB - { - var oledb = conn.OLEDBConnection; - if (oledb != null) - { - if (!string.IsNullOrWhiteSpace(definition.ConnectionString)) - { - oledb.Connection = definition.ConnectionString; - } - if (!string.IsNullOrWhiteSpace(definition.CommandText)) - { - oledb.CommandText = definition.CommandText; - } - if (definition.BackgroundQuery.HasValue) - { - oledb.BackgroundQuery = definition.BackgroundQuery.Value; - } - if (definition.RefreshOnFileOpen.HasValue) - { - oledb.RefreshOnFileOpen = definition.RefreshOnFileOpen.Value; - } - if (definition.SavePassword.HasValue) - { - oledb.SavePassword = definition.SavePassword.Value; - } - if (definition.RefreshPeriod.HasValue) - { - oledb.RefreshPeriod = definition.RefreshPeriod.Value; - } - } - } - else if (connType == 2) // ODBC - { - var odbc = conn.ODBCConnection; - if (odbc != null) - { - if (!string.IsNullOrWhiteSpace(definition.ConnectionString)) - { - odbc.Connection = definition.ConnectionString; - } - if (!string.IsNullOrWhiteSpace(definition.CommandText)) - { - odbc.CommandText = definition.CommandText; - } - if (definition.BackgroundQuery.HasValue) - { - odbc.BackgroundQuery = definition.BackgroundQuery.Value; - } - if (definition.RefreshOnFileOpen.HasValue) - { - odbc.RefreshOnFileOpen = definition.RefreshOnFileOpen.Value; - } - if (definition.SavePassword.HasValue) - { - odbc.SavePassword = definition.SavePassword.Value; - } - if (definition.RefreshPeriod.HasValue) - { - odbc.RefreshPeriod = definition.RefreshPeriod.Value; - } - } - } - else if (connType is 3 or 4) // TEXT (type 3) or WEB (type 4) - Excel may report CSV files as either - { - // Excel has type 3/4 confusion: CSV files created with "TEXT;filepath" may be reported as type 4 (WEB) - // Try TextConnection first (correct for type 3), fall back to WebConnection if that fails - dynamic? textOrWeb = null!; - try - { - textOrWeb = conn.TextConnection; // Try TEXT first - } - catch (System.Runtime.InteropServices.COMException) - { - try - { - textOrWeb = conn.WebConnection; // Fall back to WEB - } - catch (System.Runtime.InteropServices.COMException) - { - // Neither works - skip property updates - } - } - - if (textOrWeb != null) - { - if (!string.IsNullOrWhiteSpace(definition.ConnectionString)) - { - textOrWeb.Connection = definition.ConnectionString; - } - if (!string.IsNullOrWhiteSpace(definition.CommandText)) - { - textOrWeb.CommandText = definition.CommandText; - } - if (definition.BackgroundQuery.HasValue) - { - textOrWeb.BackgroundQuery = definition.BackgroundQuery.Value; - } - if (definition.RefreshOnFileOpen.HasValue) - { - textOrWeb.RefreshOnFileOpen = definition.RefreshOnFileOpen.Value; - } - if (definition.SavePassword.HasValue) - { - textOrWeb.SavePassword = definition.SavePassword.Value; - } - if (definition.RefreshPeriod.HasValue) - { - textOrWeb.RefreshPeriod = definition.RefreshPeriod.Value; - } - } - } - else if (connType == 5) // XMLMAP (moved from 4 due to type 3/4 merge) - { - // XMLMAP connection properties - future implementation - // For now, just update basic properties like description (already done above) - } - else if (connType == 6) // DATAFEED - { - // DATAFEED connection properties - future implementation - } - else if (connType == 7) // MODEL - { - // MODEL connection properties - future implementation - } - else if (connType == 8) // WORKSHEET - { - // WORKSHEET connection properties - future implementation - } - else if (connType == 9) // NOSOURCE - { - // NOSOURCE connection properties - future implementation - } - else - { - // Unknown connection type - skip property updates - // Description was already updated above if provided - } - } - catch (Exception ex) - { - throw new InvalidOperationException($"Failed to update connection properties: {ex.Message}", ex); - } - } - - private static object DetermineCommandType(ConnectionDefinition definition) - { - if (!string.IsNullOrWhiteSpace(definition.CommandType)) - { - var value = definition.CommandType.Trim(); - if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numeric)) - { - return numeric; - } - - return value.ToLowerInvariant() switch - { - "cube" => 1, - "sql" => 2, - "table" => 3, - "default" => 4, - "list" => 5, - _ => Type.Missing - }; - } - - if (!string.IsNullOrWhiteSpace(definition.CommandText)) - { - // When command text is provided we default to SQL command type (2). - return 2; - } - - return Type.Missing; - } - - private static void SetConnectionProperty<T>(dynamic conn, string propertyName, T? value) where T : struct - { - if (!value.HasValue) return; - - try - { - dynamic? subConn = GetTypedSubConnection(conn); - if (subConn != null) - { - SetProperty(subConn, propertyName, value.Value); - } - } - catch (COMException) - { - // Property not available for this connection type - } - } - - private static void SetProperty<T>(dynamic obj, string propertyName, T value) - { - try - { - // Use reflection to set property dynamically - var type = obj.GetType(); - var property = type.GetProperty(propertyName); - if (property != null && property.CanWrite) - { - property.SetValue(obj, value); - } - } - catch (System.Runtime.InteropServices.COMException) - { - // Property doesn't exist or can't be set - } - } - - private static void CreateQueryTableForConnection( - dynamic targetSheet, - dynamic conn, - PowerQueryHelpers.QueryTableOptions options) - { - // For regular connections (not Power Query), we need connection string - string? connectionString = GetConnectionString(conn); - string? commandText = GetCommandText(conn); - - if (string.IsNullOrWhiteSpace(connectionString)) - { - throw new InvalidOperationException("Connection has no connection string"); - } - - // Command text can be empty for some connection types (Text, Web) - // Use empty string if not provided - if (string.IsNullOrWhiteSpace(commandText)) - { - commandText = ""; - } - - dynamic? queryTables = null; - dynamic? queryTable = null; - dynamic? range = null; - - try - { - queryTables = targetSheet.QueryTables; - range = targetSheet.Range["A1"]; - queryTable = queryTables.Add(connectionString, range, commandText); - - queryTable.Name = options.Name.Replace(" ", "_"); - queryTable.RefreshStyle = 1; // xlInsertDeleteCells - queryTable.BackgroundQuery = options.BackgroundQuery; - queryTable.RefreshOnFileOpen = options.RefreshOnFileOpen; - queryTable.SavePassword = options.SavePassword; - queryTable.PreserveColumnInfo = options.PreserveColumnInfo; - queryTable.PreserveFormatting = options.PreserveFormatting; - queryTable.AdjustColumnWidth = options.AdjustColumnWidth; - - if (options.RefreshImmediately) - { - OleMessageFilter.EnterLongOperation(); - try - { - queryTable.Refresh(false); - } - finally - { - OleMessageFilter.ExitLongOperation(); - } - } - } - finally - { - ComUtilities.Release(ref range); - ComUtilities.Release(ref queryTable); - ComUtilities.Release(ref queryTables); - } - } - - #endregion -} - -/// <summary> -/// Connection definition for JSON import/export -/// </summary> -internal sealed class ConnectionDefinition -{ - public string Name { get; set; } = ""; - public string? Description { get; set; } - public string Type { get; set; } = ""; - public string? ConnectionString { get; set; } - public string? CommandText { get; set; } - public string? CommandType { get; set; } - public bool? BackgroundQuery { get; set; } - public bool? RefreshOnFileOpen { get; set; } - public bool? SavePassword { get; set; } - public int? RefreshPeriod { get; set; } -} - - diff --git a/src/ExcelMcp.Core/Commands/Connection/ConnectionHelpers.cs b/src/ExcelMcp.Core/Commands/Connection/ConnectionHelpers.cs deleted file mode 100644 index b88e6fe4..00000000 --- a/src/ExcelMcp.Core/Commands/Connection/ConnectionHelpers.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace Sbroenne.ExcelMcp.Core.Connections; - -/// <summary> -/// Helper methods for Excel connection operations -/// </summary> -public static class ConnectionHelpers -{ - /// <summary> - /// Gets the connection type name from XlConnectionType enum value - /// Per Microsoft docs: https://learn.microsoft.com/en-us/office/vba/api/excel.xlconnectiontype - /// </summary> - /// <param name="connectionType">Connection type numeric value</param> - /// <returns>Human-readable connection type name</returns> - public static string GetConnectionTypeName(int connectionType) - { - return connectionType switch - { - 1 => "OLEDB", - 2 => "ODBC", - 3 => "TEXT", // xlConnectionTypeTEXT (was incorrectly "XML") - 4 => "WEB", // xlConnectionTypeWEB (was incorrectly "Text") - 5 => "XMLMAP", // xlConnectionTypeXMLMAP - 6 => "DATAFEED", // xlConnectionTypeDATAFEED - 7 => "MODEL", // xlConnectionTypeMODEL - 8 => "WORKSHEET", // xlConnectionTypeWORKSHEET - 9 => "NOSOURCE", // xlConnectionTypeNOSOURCE - _ => $"Unknown ({connectionType})" - }; - } -} - - diff --git a/src/ExcelMcp.Core/Commands/Connection/IConnectionCommands.cs b/src/ExcelMcp.Core/Commands/Connection/IConnectionCommands.cs deleted file mode 100644 index fdf74335..00000000 --- a/src/ExcelMcp.Core/Commands/Connection/IConnectionCommands.cs +++ /dev/null @@ -1,130 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Attributes; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Data connections (OLEDB, ODBC, ODC import). -/// TEXT/WEB/CSV: Use powerquery instead. -/// Power Query connections auto-redirect to powerquery. -/// TIMEOUT: 30 min auto-timeout for refresh/load-to. -/// </summary> -[ServiceCategory("connection", "Connection")] -[McpTool("connection", Title = "Data Connection Operations", Destructive = true, Category = "query", - Description = "Data connections (OLEDB, ODBC, ODC import). TEXT/WEB/CSV: Use powerquery instead. Power Query connections auto-redirect to powerquery. TIMEOUT: 30 min auto-timeout for refresh/loadto.")] -public interface IConnectionCommands -{ - /// <summary> - /// Lists all connections in a workbook - /// </summary> - [ServiceAction("list")] - ConnectionListResult List(IExcelBatch batch); - - /// <summary> - /// Views detailed connection information - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="connectionName">Name of the connection to view</param> - [ServiceAction("view")] - ConnectionViewResult View( - IExcelBatch batch, - [RequiredParameter, FromString("connectionName")] string connectionName); - - /// <summary> - /// Creates a new connection in the workbook - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="connectionName">Name for the new connection</param> - /// <param name="connectionString">OLEDB or ODBC connection string</param> - /// <param name="commandText">SQL query or table name</param> - /// <param name="description">Optional description for the connection</param> - [ServiceAction("create")] - OperationResult Create( - IExcelBatch batch, - [RequiredParameter, FromString("connectionName")] string connectionName, - [RequiredParameter, FromString("connectionString")] string connectionString, - [FromString("commandText")] string? commandText = null, - [FromString("description")] string? description = null); - - /// <summary> - /// Refreshes connection data with optional timeout - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="connectionName">Name of the connection to refresh</param> - /// <param name="timeout">Optional timeout for the refresh operation</param> - [ServiceAction("refresh")] - OperationResult Refresh( - IExcelBatch batch, - [RequiredParameter, FromString("connectionName")] string connectionName, - [FromString("timeout")] TimeSpan? timeout = null); - - /// <summary> - /// Deletes a connection - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="connectionName">Name of the connection to delete</param> - [ServiceAction("delete")] - OperationResult Delete( - IExcelBatch batch, - [RequiredParameter, FromString("connectionName")] string connectionName); - - /// <summary> - /// Loads connection data to a worksheet - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="connectionName">Name of the connection</param> - /// <param name="sheetName">Target worksheet name</param> - [ServiceAction("load-to")] - OperationResult LoadTo( - IExcelBatch batch, - [RequiredParameter, FromString("connectionName")] string connectionName, - [RequiredParameter, FromString("sheetName")] string sheetName); - - /// <summary> - /// Gets connection properties - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="connectionName">Name of the connection</param> - [ServiceAction("get-properties")] - ConnectionPropertiesResult GetProperties( - IExcelBatch batch, - [RequiredParameter, FromString("connectionName")] string connectionName); - - /// <summary> - /// Sets connection properties (connection string, command text, description, and behavior settings) - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="connectionName">Name of the connection</param> - /// <param name="connectionString">New connection string (null to keep current)</param> - /// <param name="commandText">New SQL query or table name (null to keep current)</param> - /// <param name="description">New description (null to keep current)</param> - /// <param name="backgroundQuery">Run query in background (null to keep current)</param> - /// <param name="refreshOnFileOpen">Refresh when file opens (null to keep current)</param> - /// <param name="savePassword">Save password in connection (null to keep current)</param> - /// <param name="refreshPeriod">Auto-refresh interval in minutes (null to keep current)</param> - [ServiceAction("set-properties")] - OperationResult SetProperties( - IExcelBatch batch, - [RequiredParameter, FromString("connectionName")] string connectionName, - string? connectionString = null, - string? commandText = null, - string? description = null, - bool? backgroundQuery = null, - bool? refreshOnFileOpen = null, - bool? savePassword = null, - int? refreshPeriod = null); - - /// <summary> - /// Tests connection without refreshing data - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="connectionName">Name of the connection to test</param> - [ServiceAction("test")] - OperationResult Test( - IExcelBatch batch, - [RequiredParameter, FromString("connectionName")] string connectionName); -} - - - diff --git a/src/ExcelMcp.Core/Commands/CoreLookupHelpers.cs b/src/ExcelMcp.Core/Commands/CoreLookupHelpers.cs deleted file mode 100644 index 7aa87c77..00000000 --- a/src/ExcelMcp.Core/Commands/CoreLookupHelpers.cs +++ /dev/null @@ -1,227 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Shared lookup helpers for finding Excel objects (PivotTables, Tables, etc.) across the workbook. -/// These utilities centralize common lookup patterns to avoid code duplication. -/// </summary> -public static class CoreLookupHelpers -{ - #region PivotTable Lookup - - /// <summary> - /// Tries to find a PivotTable by name across all worksheets. - /// </summary> - /// <param name="workbook">The workbook to search</param> - /// <param name="pivotTableName">Name of the PivotTable to find</param> - /// <param name="pivotTable">The found PivotTable object (caller must release), or null if not found</param> - /// <returns>True if found, false otherwise</returns> - /// <remarks> - /// Caller is responsible for releasing the returned COM object using ComUtilities.Release(). - /// </remarks> - public static bool TryFindPivotTable(dynamic workbook, string pivotTableName, out dynamic? pivotTable) - { - pivotTable = null; - dynamic? sheets = null; - - try - { - sheets = workbook.Worksheets; - int sheetCount = Convert.ToInt32(sheets.Count); - - for (int i = 1; i <= sheetCount; i++) - { - dynamic? sheet = null; - dynamic? pivotTables = null; - - try - { - sheet = sheets.Item(i); - pivotTables = sheet.PivotTables(); - int ptCount = Convert.ToInt32(pivotTables.Count); - - for (int j = 1; j <= ptCount; j++) - { - dynamic? pt = null; - - try - { - pt = pivotTables.Item(j); - string ptName = pt.Name?.ToString() ?? string.Empty; - - if (ptName.Equals(pivotTableName, StringComparison.OrdinalIgnoreCase)) - { - // Found - release intermediate objects but NOT the found PivotTable - ComUtilities.Release(ref pivotTables!); - ComUtilities.Release(ref sheet!); - ComUtilities.Release(ref sheets!); - pivotTable = pt; - return true; - } - } - finally - { - // Only release if not the found item - if (pt != null && pivotTable == null) - { - ComUtilities.Release(ref pt!); - } - } - } - } - finally - { - ComUtilities.Release(ref pivotTables); - ComUtilities.Release(ref sheet); - } - } - } - finally - { - ComUtilities.Release(ref sheets); - } - - return false; - } - - /// <summary> - /// Finds a PivotTable by name across all worksheets, throwing if not found. - /// </summary> - /// <param name="workbook">The workbook to search</param> - /// <param name="pivotTableName">Name of the PivotTable to find</param> - /// <returns>The PivotTable object (caller must release)</returns> - /// <exception cref="InvalidOperationException">Thrown if PivotTable is not found</exception> - /// <remarks> - /// Caller is responsible for releasing the returned COM object using ComUtilities.Release(). - /// </remarks> - public static dynamic FindPivotTable(dynamic workbook, string pivotTableName) - { - if (!TryFindPivotTable(workbook, pivotTableName, out dynamic? pivotTable) || pivotTable == null) - { - throw new InvalidOperationException($"PivotTable '{pivotTableName}' not found."); - } - - return pivotTable!; - } - - #endregion - - #region Table Lookup - - /// <summary> - /// Tries to find an Excel Table (ListObject) by name in the workbook. - /// </summary> - /// <param name="workbook">The workbook to search</param> - /// <param name="tableName">Name of the table to find</param> - /// <param name="table">The found table object (caller must release), or null if not found</param> - /// <returns>True if found, false otherwise</returns> - /// <remarks> - /// Caller is responsible for releasing the returned COM object using ComUtilities.Release(). - /// </remarks> - public static bool TryFindTable(dynamic workbook, string tableName, out dynamic? table) - { - table = null; - dynamic? sheets = null; - - try - { - sheets = workbook.Worksheets; - int sheetCount = Convert.ToInt32(sheets.Count); - - for (int i = 1; i <= sheetCount; i++) - { - dynamic? sheet = null; - dynamic? listObjects = null; - - try - { - sheet = sheets.Item(i); - listObjects = sheet.ListObjects; - int tableCount = Convert.ToInt32(listObjects.Count); - - for (int j = 1; j <= tableCount; j++) - { - dynamic? tbl = null; - - try - { - tbl = listObjects.Item(j); - string tblName = tbl.Name?.ToString() ?? string.Empty; - - if (tblName.Equals(tableName, StringComparison.OrdinalIgnoreCase)) - { - // Found - release intermediate objects but NOT the found table - ComUtilities.Release(ref listObjects!); - ComUtilities.Release(ref sheet!); - ComUtilities.Release(ref sheets!); - table = tbl; - return true; - } - } - finally - { - // Only release if not the found item - if (tbl != null && table == null) - { - ComUtilities.Release(ref tbl!); - } - } - } - } - finally - { - ComUtilities.Release(ref listObjects); - ComUtilities.Release(ref sheet); - } - } - } - finally - { - ComUtilities.Release(ref sheets); - } - - return false; - } - - /// <summary> - /// Finds an Excel Table (ListObject) by name in the workbook, throwing if not found. - /// </summary> - /// <param name="workbook">The workbook to search</param> - /// <param name="tableName">Name of the table to find</param> - /// <returns>The table object (caller must release)</returns> - /// <exception cref="InvalidOperationException">Thrown if table is not found</exception> - /// <remarks> - /// Caller is responsible for releasing the returned COM object using ComUtilities.Release(). - /// </remarks> - public static dynamic FindTable(dynamic workbook, string tableName) - { - if (!TryFindTable(workbook, tableName, out dynamic? table) || table == null) - { - throw new InvalidOperationException($"Table '{tableName}' not found."); - } - - return table!; - } - - /// <summary> - /// Checks if a table with the given name exists in the workbook. - /// </summary> - /// <param name="workbook">The workbook to search</param> - /// <param name="tableName">Name of the table to check</param> - /// <returns>True if table exists, false otherwise</returns> - public static bool TableExists(dynamic workbook, string tableName) - { - if (TryFindTable(workbook, tableName, out dynamic? table)) - { - ComUtilities.Release(ref table); - return true; - } - - return false; - } - - #endregion -} - - diff --git a/src/ExcelMcp.Core/Commands/DataModel/DataModelCommands.Dmv.cs b/src/ExcelMcp.Core/Commands/DataModel/DataModelCommands.Dmv.cs deleted file mode 100644 index 3eb26d40..00000000 --- a/src/ExcelMcp.Core/Commands/DataModel/DataModelCommands.Dmv.cs +++ /dev/null @@ -1,176 +0,0 @@ -using System.Runtime.InteropServices; - -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.DataModel; -using Sbroenne.ExcelMcp.Core.Models; -using Excel = Microsoft.Office.Interop.Excel; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Data Model DMV (Dynamic Management View) query execution -/// </summary> -public partial class DataModelCommands -{ - /// <inheritdoc /> - public DmvQueryResult ExecuteDmv(IExcelBatch batch, string dmvQuery) - { - // Validate input - if (string.IsNullOrWhiteSpace(dmvQuery)) - { - throw new ArgumentException("dmvQuery is required for execute-dmv action", nameof(dmvQuery)); - } - - var result = new DmvQueryResult - { - FilePath = batch.WorkbookPath, - DmvQuery = dmvQuery - }; - - using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); - - return batch.Execute((ctx, ct) => - { - Excel.Model? model = null; - Excel.WorkbookConnection? dataModelConn = null; - Excel.ModelConnection? modelConn = null; - dynamic? adoConnection = null; - dynamic? recordset = null; - dynamic? fields = null; - - try - { - // Check if workbook has Data Model - if (!HasDataModelTables(ctx.Book)) - { - throw new InvalidOperationException(DataModelErrorMessages.NoDataModelTables()); - } - - model = ctx.Book.Model; - - // Get the DataModelConnection (Type 7 connection to embedded Analysis Services) - dataModelConn = model!.DataModelConnection; - if (dataModelConn == null) - { - throw new InvalidOperationException("No DataModelConnection available - workbook may not have a Data Model"); - } - - modelConn = dataModelConn.ModelConnection; - if (modelConn == null) - { - throw new InvalidOperationException("No ModelConnection available from DataModelConnection"); - } - - // Get the ADO connection - this is a live MSOLAP connection to the embedded AS engine - adoConnection = modelConn.ADOConnection; - if (adoConnection == null) - { - throw new InvalidOperationException("No ADOConnection available - cannot execute DMV query"); - } - - // Execute the DMV query directly via ADO - // DMV queries use SQL-like syntax: SELECT * FROM $SYSTEM.TMSCHEMA_TABLES - // Wrap in try-catch to provide helpful error message when MSOLAP is missing - try - { - recordset = adoConnection.Execute(dmvQuery); - } - catch (COMException ex) when (ex.HResult == unchecked((int)0x80040154)) - { - // REGDB_E_CLASSNOTREG (0x80040154) = "Class not registered" - // This occurs when MSOLAP provider is not installed - throw new InvalidOperationException(DataModelErrorMessages.MsolapProviderNotInstalled(), ex); - } - - // Get field (column) information - fields = recordset.Fields; - int fieldCount = fields.Count; - result.ColumnCount = fieldCount; - - // Extract column names - for (int i = 0; i < fieldCount; i++) - { - dynamic? field = null; - try - { - field = fields.Item(i); - string fieldName = field.Name?.ToString() ?? $"Column{i}"; - result.Columns.Add(fieldName); - } - finally - { - ComUtilities.Release(ref field); - } - } - - // Read all rows from the recordset - while (!recordset.EOF) - { - var row = new List<object?>(); - - for (int i = 0; i < fieldCount; i++) - { - dynamic? field = null; - try - { - field = fields.Item(i); - object? value = field.Value; - - // Convert DBNull to null - if (value == DBNull.Value || value == null) - { - row.Add(null); - } - else - { - // Convert to JSON-friendly types - row.Add(ConvertToJsonFriendly(value)); - } - } - finally - { - ComUtilities.Release(ref field); - } - } - - result.Rows.Add(row); - recordset.MoveNext(); - } - - result.RowCount = result.Rows.Count; - result.Success = true; - } - finally - { - // Close recordset if open - if (recordset != null) - { - try - { - // State 1 = adStateOpen - if ((int)recordset.State == 1) - { - recordset.Close(); - } - } - catch (System.Runtime.InteropServices.COMException) - { - // Ignore errors closing recordset - } - } - - ComUtilities.Release(ref fields); - ComUtilities.Release(ref recordset); - ComUtilities.Release(ref adoConnection); - ComUtilities.Release(ref modelConn); - ComUtilities.Release(ref dataModelConn); - ComUtilities.Release(ref model); - } - - return result; - }, timeoutCts.Token); - } -} - - diff --git a/src/ExcelMcp.Core/Commands/DataModel/DataModelCommands.Evaluate.cs b/src/ExcelMcp.Core/Commands/DataModel/DataModelCommands.Evaluate.cs deleted file mode 100644 index 088f3a88..00000000 --- a/src/ExcelMcp.Core/Commands/DataModel/DataModelCommands.Evaluate.cs +++ /dev/null @@ -1,202 +0,0 @@ -using System.Runtime.InteropServices; - -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.DataModel; -using Sbroenne.ExcelMcp.Core.Models; -using Excel = Microsoft.Office.Interop.Excel; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Data Model DAX EVALUATE query execution -/// </summary> -public partial class DataModelCommands -{ - /// <inheritdoc /> - public DaxEvaluateResult Evaluate(IExcelBatch batch, string daxQuery) - { - // Validate input - if (string.IsNullOrWhiteSpace(daxQuery)) - { - throw new ArgumentException("daxQuery is required for evaluate action", nameof(daxQuery)); - } - - var result = new DaxEvaluateResult - { - FilePath = batch.WorkbookPath, - DaxQuery = daxQuery - }; - - using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); - - return batch.Execute((ctx, ct) => - { - Excel.Model? model = null; - Excel.WorkbookConnection? dataModelConn = null; - Excel.ModelConnection? modelConn = null; - dynamic? adoConnection = null; - dynamic? recordset = null; - dynamic? fields = null; - - try - { - // Check if workbook has Data Model - if (!HasDataModelTables(ctx.Book)) - { - throw new InvalidOperationException(DataModelErrorMessages.NoDataModelTables()); - } - - model = ctx.Book.Model; - - // Get the DataModelConnection (Type 7 connection to embedded Analysis Services) - dataModelConn = model!.DataModelConnection; - if (dataModelConn == null) - { - throw new InvalidOperationException("No DataModelConnection available - workbook may not have a Data Model"); - } - - modelConn = dataModelConn.ModelConnection; - if (modelConn == null) - { - throw new InvalidOperationException("No ModelConnection available from DataModelConnection"); - } - - // Get the ADO connection - this is a live MSOLAP connection to the embedded AS engine - adoConnection = modelConn.ADOConnection; - if (adoConnection == null) - { - throw new InvalidOperationException("No ADOConnection available - cannot execute DAX query"); - } - - // Execute the DAX EVALUATE query directly via ADO - // Wrap in try-catch to provide helpful error message when MSOLAP is missing - try - { - recordset = adoConnection.Execute(daxQuery); - } - catch (COMException ex) when (ex.HResult == unchecked((int)0x80040154)) - { - // REGDB_E_CLASSNOTREG (0x80040154) = "Class not registered" - // This occurs when MSOLAP provider is not installed - throw new InvalidOperationException(DataModelErrorMessages.MsolapProviderNotInstalled(), ex); - } - - // Get field (column) information - fields = recordset.Fields; - int fieldCount = fields.Count; - result.ColumnCount = fieldCount; - - // Extract column names (fully qualified: Table[Column]) - for (int i = 0; i < fieldCount; i++) - { - dynamic? field = null; - try - { - field = fields.Item(i); - string fieldName = field.Name?.ToString() ?? $"Column{i}"; - result.Columns.Add(fieldName); - } - finally - { - ComUtilities.Release(ref field); - } - } - - // Read all rows from the recordset - while (!recordset.EOF) - { - var row = new List<object?>(); - - for (int i = 0; i < fieldCount; i++) - { - dynamic? field = null; - try - { - field = fields.Item(i); - object? value = field.Value; - - // Convert DBNull to null - if (value == DBNull.Value || value == null) - { - row.Add(null); - } - else - { - // Convert to JSON-friendly types - row.Add(ConvertToJsonFriendly(value)); - } - } - finally - { - ComUtilities.Release(ref field); - } - } - - result.Rows.Add(row); - recordset.MoveNext(); - } - - result.RowCount = result.Rows.Count; - result.Success = true; - } - finally - { - // Close recordset if open - if (recordset != null) - { - try - { - // State 1 = adStateOpen - if ((int)recordset.State == 1) - { - recordset.Close(); - } - } - catch (System.Runtime.InteropServices.COMException) - { - // Ignore errors closing recordset - } - } - - ComUtilities.Release(ref fields); - ComUtilities.Release(ref recordset); - ComUtilities.Release(ref adoConnection); - ComUtilities.Release(ref modelConn); - ComUtilities.Release(ref dataModelConn); - ComUtilities.Release(ref model); - } - - return result; - }, timeoutCts.Token); - } - - /// <summary> - /// Converts a value from ADO recordset to a JSON-friendly type - /// </summary> - private static object ConvertToJsonFriendly(object value) - { - return value switch - { - // Dates - DateTime dt => dt.ToString("O"), // ISO 8601 format - - // Numbers - preserve precision - decimal d => d, - double dbl => dbl, - float f => f, - long l => l, - int i => i, - short s => s, - byte b => b, - - // Booleans - bool bl => bl, - - // Strings and others - convert to string - _ => value.ToString() ?? "" - }; - } -} - - diff --git a/src/ExcelMcp.Core/Commands/DataModel/DataModelCommands.Helpers.cs b/src/ExcelMcp.Core/Commands/DataModel/DataModelCommands.Helpers.cs deleted file mode 100644 index 62a8de08..00000000 --- a/src/ExcelMcp.Core/Commands/DataModel/DataModelCommands.Helpers.cs +++ /dev/null @@ -1,515 +0,0 @@ -using System.Runtime.InteropServices; -using Microsoft.CSharp.RuntimeBinder; -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.Core.Models; -using Excel = Microsoft.Office.Interop.Excel; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Private helper methods for DataModel commands -/// </summary> -public partial class DataModelCommands -{ - /// <summary> - /// Gets all measure names from the Data Model - /// </summary> - /// <param name="model">Model COM object</param> - /// <returns>List of measure names</returns> - private static List<string> GetModelMeasureNames(Excel.Model model) - { - var names = new List<string>(); - Excel.ModelMeasures? measures = null; - try - { - // Get measures collection from MODEL (not from tables!) - // Reference: https://learn.microsoft.com/en-us/office/vba/api/excel.model.modelmeasures - measures = model.ModelMeasures; - - for (int m = 1; m <= measures.Count; m++) - { - Excel.ModelMeasure? measure = null; - try - { - measure = measures.Item(m); - names.Add(measure.Name?.ToString() ?? ""); - } - finally - { - ComUtilities.Release(ref measure); - } - } - } - finally - { - ComUtilities.Release(ref measures); - } - return names; - } - - /// <summary> - /// Gets the table name that contains a specific measure - /// </summary> - /// <param name="model">Model COM object</param> - /// <param name="measureName">Measure name to find</param> - /// <returns>Table name if found, null otherwise</returns> - private static string? GetMeasureTableName(Excel.Model model, string measureName) - { - Excel.ModelMeasures? measures = null; - try - { - // Get measures collection from MODEL (not from table!) - // All measures are at model level with AssociatedTable property - // Reference: https://learn.microsoft.com/en-us/office/vba/api/excel.model.modelmeasures - measures = model.ModelMeasures; - - for (int m = 1; m <= measures.Count; m++) - { - Excel.ModelMeasure? measure = null; - try - { - measure = measures.Item(m); - string name = measure.Name?.ToString() ?? ""; - if (name.Equals(measureName, StringComparison.OrdinalIgnoreCase)) - { - // Get the associated table name - Excel.ModelTable? associatedTable = null; - try - { - associatedTable = measure.AssociatedTable; - return associatedTable?.Name?.ToString() ?? string.Empty; - } - finally - { - ComUtilities.Release(ref associatedTable); - } - } - } - finally - { - ComUtilities.Release(ref measure); - } - } - } - finally - { - ComUtilities.Release(ref measures); - } - return null; - } - - /// <summary> - /// Finds a column in a model table by name (case-insensitive) - /// </summary> - /// <param name="table">Table COM object</param> - /// <param name="columnName">Column name to find</param> - /// <returns>Column COM object or null if not found</returns> - private static Excel.ModelTableColumn? FindModelTableColumn(Excel.ModelTable table, string columnName) - { - Excel.ModelTableColumns? columns = null; - try - { - columns = table.ModelTableColumns; - int count = columns.Count; - - for (int i = 1; i <= count; i++) - { - Excel.ModelTableColumn? column = null; - try - { - column = columns.Item(i); - string currentName = column.Name?.ToString() ?? ""; - - if (currentName.Equals(columnName, StringComparison.OrdinalIgnoreCase)) - { - // Found match - don't release, caller will use it - var foundColumn = column; - column = null; // Prevent release in finally - return foundColumn; - } - } - finally - { - // Only release if we didn't return it - if (column != null) - { - ComUtilities.Release(ref column); - } - } - } - - return null; - } - finally - { - ComUtilities.Release(ref columns); - } - } - - /// <summary> - /// Gets the appropriate format object from the model for measure creation - /// </summary> - /// <param name="model">Model COM object</param> - /// <param name="formatType">Format type (Currency, Decimal, Percentage, General)</param> - /// <returns>FormatInformation COM object (never null - always returns at least ModelFormatGeneral)</returns> - private static object GetFormatObject(Excel.Model model, string? formatType) - { - // CRITICAL FIX: FormatInformation parameter is REQUIRED by Excel COM API - // Microsoft docs state it's required, but behavior is inconsistent: - // - Fresh Data Model files: Accept Type.Missing (works) - // - Reopened Data Model files: Require actual format object (fails with Type.Missing) - // Solution: Always return a format object - use ModelFormatGeneral as default - // See: docs/KNOWN-ISSUES.md for investigation details - - if (string.IsNullOrEmpty(formatType) || formatType.Equals("General", StringComparison.OrdinalIgnoreCase)) - { - return model.ModelFormatGeneral; // Default format - } - - try - { - return formatType.ToLowerInvariant() switch - { - "currency" => (object)model.ModelFormatCurrency, - "decimal" => (object)model.ModelFormatDecimalNumber, - "percentage" => (object)model.ModelFormatPercentageNumber, - "wholenumber" => (object)model.ModelFormatWholeNumber, - _ => (object)model.ModelFormatGeneral // Fallback to General for unknown types - }; - } - catch (Exception ex) when (ex is COMException or RuntimeBinderException) - { - // COM format object not available - use General as safe fallback - return model.ModelFormatGeneral; - } - } - - /// <summary> - /// Extracts format information from a ModelFormat* COM object as a structured object. - /// FormatInformation returns one of: ModelFormatGeneral, ModelFormatCurrency, - /// ModelFormatDecimalNumber, ModelFormatPercentageNumber, ModelFormatWholeNumber, - /// ModelFormatScientificNumber, ModelFormatBoolean, ModelFormatDate. - /// These don't have a FormatString property - they have type-specific properties like DecimalPlaces, Symbol. - /// </summary> - /// <param name="formatInfo">The FormatInformation COM object from a ModelMeasure</param> - /// <returns>Structured format info with Type, Symbol, DecimalPlaces, UseThousandSeparator as applicable</returns> - private static MeasureFormatInfo GetFormatInfo(dynamic formatInfo) - { - var result = new MeasureFormatInfo { Type = "General" }; - - try - { - // Try to detect the format type by checking for type-specific properties - // Each ModelFormat* type has different properties available - - // Check for Currency (has Symbol and DecimalPlaces) - // COM property probing: access throws COMException or RuntimeBinderException if property doesn't exist on this format type - try - { - string? symbol = formatInfo.Symbol?.ToString(); - if (!string.IsNullOrEmpty(symbol)) - { - result.Type = "Currency"; - result.Symbol = symbol; - result.DecimalPlaces = Convert.ToInt32(formatInfo.DecimalPlaces); - return result; - } - } - catch (Exception ex) when (ex is COMException or RuntimeBinderException) { /* Not a currency format - property doesn't exist */ } - - // Check for Percentage (has DecimalPlaces and UseThousandSeparator) - try - { - // Percentage format has UseThousandSeparator but no Symbol - bool useThousands = formatInfo.UseThousandSeparator; - int decimals = Convert.ToInt32(formatInfo.DecimalPlaces); - // If we got here without exception, it's likely Percentage or Decimal - result.Type = "Percentage"; - result.DecimalPlaces = decimals; - result.UseThousandSeparator = useThousands; - return result; - } - catch (Exception ex) when (ex is COMException or RuntimeBinderException) { /* Not a percentage format - property doesn't exist */ } - - // Check for DecimalNumber or WholeNumber (has DecimalPlaces) - try - { - int decimals = Convert.ToInt32(formatInfo.DecimalPlaces); - result.Type = decimals == 0 ? "WholeNumber" : "Decimal"; - result.DecimalPlaces = decimals; - return result; - } - catch (Exception ex) when (ex is COMException or RuntimeBinderException) { /* Not a decimal format - property doesn't exist */ } - - // Default to General if we can't determine the type - return result; - } - catch (Exception ex) when (ex is COMException or RuntimeBinderException) - { - // COM object access failed entirely - return default General format - return result; - } - } - - /// <summary> - /// Finds a relationship between two tables by column names - /// </summary> - /// <param name="model">Model COM object</param> - /// <param name="fromTable">From table name</param> - /// <param name="fromColumn">From column name</param> - /// <param name="toTable">To table name</param> - /// <param name="toColumn">To column name</param> - /// <returns>Relationship COM object or null if not found</returns> - private static Excel.ModelRelationship? FindRelationship(Excel.Model model, string fromTable, string fromColumn, string toTable, string toColumn) - { - Excel.ModelRelationships? relationships = null; - try - { - relationships = model.ModelRelationships; - int count = relationships.Count; - - for (int i = 1; i <= count; i++) - { - Excel.ModelRelationship? relationship = null; - try - { - relationship = relationships.Item(i); - - // Get relationship details - string currentFromTable = (relationship.ForeignKeyColumn?.Parent as Excel.ModelTable)?.Name?.ToString() ?? ""; - string currentFromColumn = relationship.ForeignKeyColumn?.Name?.ToString() ?? ""; - string currentToTable = (relationship.PrimaryKeyColumn?.Parent as Excel.ModelTable)?.Name?.ToString() ?? ""; - string currentToColumn = relationship.PrimaryKeyColumn?.Name?.ToString() ?? ""; - - // Match all four components (case-insensitive) - if (currentFromTable.Equals(fromTable, StringComparison.OrdinalIgnoreCase) && - currentFromColumn.Equals(fromColumn, StringComparison.OrdinalIgnoreCase) && - currentToTable.Equals(toTable, StringComparison.OrdinalIgnoreCase) && - currentToColumn.Equals(toColumn, StringComparison.OrdinalIgnoreCase)) - { - // Found match - don't release, caller will use it - var foundRelationship = relationship; - relationship = null; // Prevent release in finally - return foundRelationship; - } - } - finally - { - // Only release if we didn't return it - if (relationship != null) - { - ComUtilities.Release(ref relationship); - } - } - } - - return null; - } - finally - { - ComUtilities.Release(ref relationships); - } - } - - /// <summary> - /// Checks if the Data Model has any tables - /// NOTE: Every workbook has a Model object, but it may be empty - /// </summary> - private static bool HasDataModelTables(Excel.Workbook workbook) - { - Excel.Model? model = null; - Excel.ModelTables? modelTables = null; - try - { - model = workbook.Model; - if (model == null) return false; - - modelTables = model.ModelTables; - return modelTables != null && modelTables.Count > 0; - } - finally - { - ComUtilities.Release(ref modelTables); - ComUtilities.Release(ref model); - } - } - - /// <summary> - /// Finds a table in the Data Model by name - /// </summary> - private static Excel.ModelTable? FindModelTable(Excel.Model model, string tableName) - { - Excel.ModelTables? modelTables = null; - try - { - modelTables = model.ModelTables; - for (int i = 1; i <= modelTables.Count; i++) - { - Excel.ModelTable? table = null; - try - { - table = modelTables.Item(i); - string name = table.Name?.ToString() ?? ""; - if (name.Equals(tableName, StringComparison.OrdinalIgnoreCase)) - { - var result = table; - table = null; // Don't release - returning it - return result; - } - } - finally - { - ComUtilities.Release(ref table); - } - } - } - finally - { - ComUtilities.Release(ref modelTables); - } - return null; - } - - /// <summary> - /// Finds a DAX measure by name in the Data Model - /// </summary> - private static Excel.ModelMeasure? FindModelMeasure(Excel.Model model, string measureName) - { - Excel.ModelMeasures? measures = null; - try - { - // Get measures collection from MODEL (not from table!) - // All measures are at model level with AssociatedTable property - // Reference: https://learn.microsoft.com/en-us/office/vba/api/excel.model.modelmeasures - measures = model.ModelMeasures; - int count = measures.Count; - - for (int i = 1; i <= count; i++) - { - Excel.ModelMeasure? measure = null; - try - { - measure = measures.Item(i); - string name = measure.Name?.ToString() ?? ""; - if (name.Equals(measureName, StringComparison.OrdinalIgnoreCase)) - { - // Found match - don't release, caller will use it - var result = measure; - measure = null; // Prevent release in finally - return result; - } - } - finally - { - // Only release if we didn't return it - if (measure != null) - { - ComUtilities.Release(ref measure); - } - } - } - - return null; - } - finally - { - ComUtilities.Release(ref measures); - } - } - - /// <summary> - /// Safely iterates through all tables in the Data Model - /// </summary> - private static void ForEachTable(Excel.Model model, Action<Excel.ModelTable, int> action) - { - Excel.ModelTables? modelTables = null; - try - { - modelTables = model.ModelTables; - int count = modelTables.Count; - - for (int i = 1; i <= count; i++) - { - Excel.ModelTable? table = null; - try - { - table = modelTables.Item(i); - action(table, i); - } - finally - { - ComUtilities.Release(ref table); - } - } - } - finally - { - ComUtilities.Release(ref modelTables); - } - } - - /// <summary> - /// Safely iterates through all measures in the Data Model - /// </summary> - private static void ForEachMeasure(Excel.Model model, Action<Excel.ModelMeasure, int> action) - { - Excel.ModelMeasures? measures = null; - try - { - // Get measures collection from MODEL (not from table!) - measures = model.ModelMeasures; - int count = measures.Count; - - for (int i = 1; i <= count; i++) - { - Excel.ModelMeasure? measure = null; - try - { - measure = measures.Item(i); - action(measure, i); - } - finally - { - ComUtilities.Release(ref measure); - } - } - } - finally - { - ComUtilities.Release(ref measures); - } - } - - /// <summary> - /// Safely iterates through all relationships in the Data Model - /// </summary> - private static void ForEachRelationship(Excel.Model model, Action<Excel.ModelRelationship, int> action) - { - Excel.ModelRelationships? relationships = null; - try - { - relationships = model.ModelRelationships; - int count = relationships.Count; - - for (int i = 1; i <= count; i++) - { - Excel.ModelRelationship? relationship = null; - try - { - relationship = relationships.Item(i); - action(relationship, i); - } - finally - { - ComUtilities.Release(ref relationship); - } - } - } - finally - { - ComUtilities.Release(ref relationships); - } - } -} - - diff --git a/src/ExcelMcp.Core/Commands/DataModel/DataModelCommands.Read.cs b/src/ExcelMcp.Core/Commands/DataModel/DataModelCommands.Read.cs deleted file mode 100644 index e2eb25d8..00000000 --- a/src/ExcelMcp.Core/Commands/DataModel/DataModelCommands.Read.cs +++ /dev/null @@ -1,515 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.DataModel; -using Sbroenne.ExcelMcp.Core.Models; -using Excel = Microsoft.Office.Interop.Excel; - - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Data Model Read operations - List, View, Export -/// </summary> -public partial class DataModelCommands -{ - /// <inheritdoc /> - public DataModelTableListResult ListTables(IExcelBatch batch) - { - var result = new DataModelTableListResult { FilePath = batch.WorkbookPath }; - - using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); - - return batch.Execute((ctx, ct) => - { - // Check if workbook has Data Model - if (!HasDataModelTables(ctx.Book)) - { - // Empty Data Model is valid - return empty list (LLM-friendly) - result.Success = true; - result.Tables = []; - return result; - } - - Excel.Model? model = null; - try - { - model = ctx.Book.Model; - - ForEachTable(model!, (table, index) => - { - var tableInfo = new DataModelTableInfo - { - Name = ComUtilities.SafeGetString(table, "Name"), - SourceName = ComUtilities.SafeGetString(table, "SourceName"), - RecordCount = ComUtilities.SafeGetInt(table, "RecordCount") - }; - - result.Tables.Add(tableInfo); - }); - - result.Success = true; - } - finally - { - ComUtilities.Release(ref model); - } - - return result; - }, timeoutCts.Token); - } - - /// <inheritdoc /> - public DataModelMeasureListResult ListMeasures(IExcelBatch batch, string? tableName = null) - { - var result = new DataModelMeasureListResult { FilePath = batch.WorkbookPath }; - - using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); - - result = batch.Execute((ctx, ct) => - { - Excel.Model? model = null; - try - { - // Check if workbook has Data Model - if (!HasDataModelTables(ctx.Book)) - { - throw new InvalidOperationException(DataModelErrorMessages.NoDataModelTables()); - } - - model = ctx.Book.Model; - - // Iterate through all measures (they're at model level) - ForEachMeasure(model!, (measure, index) => - { - // Get the table name for this measure - string measureTableName = string.Empty; - Excel.ModelTable? associatedTable = null; - try - { - associatedTable = measure.AssociatedTable; - measureTableName = associatedTable?.Name?.ToString() ?? string.Empty; - } - finally - { - ComUtilities.Release(ref associatedTable); - } - - // Skip if filtering by table and this measure isn't in that table - if (tableName != null && !measureTableName.Equals(tableName, StringComparison.OrdinalIgnoreCase)) - { - return; - } - - string formula = ComUtilities.SafeGetString(measure, "Formula"); - - var measureInfo = new DataModelMeasureInfo - { - Name = ComUtilities.SafeGetString(measure, "Name"), - Table = measureTableName, - FormulaPreview = formula, // Will be formatted after retrieval - Description = ComUtilities.SafeGetString(measure, "Description") - }; - - result.Measures.Add(measureInfo); - }); - - // Check if table filter was specified but not found - if (tableName != null && result.Measures.Count == 0) - { - throw new InvalidOperationException(DataModelErrorMessages.TableNotFound(tableName)); - } - - result.Success = true; - } - finally - { - ComUtilities.Release(ref model); - } - - return result; - }, timeoutCts.Token); - - return result; - } - - /// <inheritdoc /> - public DataModelMeasureViewResult Read(IExcelBatch batch, string measureName) - { - var result = new DataModelMeasureViewResult - { - FilePath = batch.WorkbookPath, - MeasureName = measureName - }; - - using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); - - result = batch.Execute((ctx, ct) => - { - Excel.Model? model = null; - Excel.ModelMeasure? measure = null; - try - { - // Check if workbook has Data Model - if (!HasDataModelTables(ctx.Book)) - { - throw new InvalidOperationException(DataModelErrorMessages.NoDataModelTables()); - } - - model = ctx.Book.Model; - - // Find the measure - measure = FindModelMeasure(model!, measureName); - if (measure == null) - { - throw new InvalidOperationException(DataModelErrorMessages.MeasureNotFound(measureName)); - } - - // Get measure details using safe helpers - result.DaxFormula = ComUtilities.SafeGetString(measure, "Formula"); - result.Description = ComUtilities.SafeGetString(measure, "Description"); - result.CharacterCount = result.DaxFormula.Length; - result.TableName = GetMeasureTableName(model!, measureName) ?? ""; - - // Try to get format information - FormatInformation returns ModelFormat* objects - // (ModelFormatGeneral, ModelFormatCurrency, ModelFormatDecimalNumber, etc.) - // These don't have a FormatString property - they have type-specific properties - object? formatInfo = null; - try - { - formatInfo = measure.FormatInformation; - if (formatInfo != null) - { - // PIA gap: ModelMeasure.FormatInformation returns object in PIA; cast to dynamic for runtime property probing in GetFormatInfo - result.FormatInfo = GetFormatInfo((dynamic)formatInfo); - } - } - finally - { - ComUtilities.Release(ref formatInfo); - } - - result.Success = true; - } - finally - { - ComUtilities.Release(ref measure); - ComUtilities.Release(ref model); - } - - return result; - }); - - return result; - } - - /// <inheritdoc /> - public DataModelRelationshipListResult ListRelationships(IExcelBatch batch) - { - var result = new DataModelRelationshipListResult { FilePath = batch.WorkbookPath }; - - return batch.Execute((ctx, ct) => - { - Excel.Model? model = null; - try - { - // Check if workbook has Data Model - if (!HasDataModelTables(ctx.Book)) - { - throw new InvalidOperationException(DataModelErrorMessages.NoDataModelTables()); - } - - model = ctx.Book.Model; - - ForEachRelationship(model!, (relationship, index) => - { - var relInfo = new DataModelRelationshipInfo - { - FromTable = ComUtilities.SafeGetString(relationship.ForeignKeyColumn.Parent, "Name"), - FromColumn = ComUtilities.SafeGetString(relationship.ForeignKeyColumn, "Name"), - ToTable = ComUtilities.SafeGetString(relationship.PrimaryKeyColumn.Parent, "Name"), - ToColumn = ComUtilities.SafeGetString(relationship.PrimaryKeyColumn, "Name"), - IsActive = relationship.Active - }; - - result.Relationships.Add(relInfo); - }); - - result.Success = true; - } - finally - { - ComUtilities.Release(ref model); - } - - return result; - }); - } - - /// <inheritdoc /> - public DataModelRelationshipViewResult ReadRelationship(IExcelBatch batch, string fromTable, string fromColumn, string toTable, string toColumn) - { - var result = new DataModelRelationshipViewResult { FilePath = batch.WorkbookPath }; - - return batch.Execute((ctx, ct) => - { - Excel.Model? model = null; - try - { - // Check if workbook has Data Model - if (!HasDataModelTables(ctx.Book)) - { - throw new InvalidOperationException(DataModelErrorMessages.NoDataModelTables()); - } - - model = ctx.Book.Model; - - // Find the matching relationship - DataModelRelationshipInfo? foundRelationship = null; - ForEachRelationship(model!, (relationship, index) => - { - var relFromTable = ComUtilities.SafeGetString(relationship.ForeignKeyColumn.Parent, "Name"); - var relFromColumn = ComUtilities.SafeGetString(relationship.ForeignKeyColumn, "Name"); - var relToTable = ComUtilities.SafeGetString(relationship.PrimaryKeyColumn.Parent, "Name"); - var relToColumn = ComUtilities.SafeGetString(relationship.PrimaryKeyColumn, "Name"); - - if (string.Equals(relFromTable, fromTable, StringComparison.OrdinalIgnoreCase) && - string.Equals(relFromColumn, fromColumn, StringComparison.OrdinalIgnoreCase) && - string.Equals(relToTable, toTable, StringComparison.OrdinalIgnoreCase) && - string.Equals(relToColumn, toColumn, StringComparison.OrdinalIgnoreCase)) - { - foundRelationship = new DataModelRelationshipInfo - { - FromTable = relFromTable, - FromColumn = relFromColumn, - ToTable = relToTable, - ToColumn = relToColumn, - IsActive = relationship.Active - }; - } - }); - - if (foundRelationship == null) - { - throw new InvalidOperationException( - $"Relationship not found: {fromTable}.{fromColumn} -> {toTable}.{toColumn}"); - } - - result.Relationship = foundRelationship; - result.Success = true; - } - finally - { - ComUtilities.Release(ref model); - } - - return result; - }); - } - - /// <inheritdoc /> - public DataModelTableColumnsResult ListColumns(IExcelBatch batch, string tableName) - { - var result = new DataModelTableColumnsResult - { - FilePath = batch.WorkbookPath, - TableName = tableName - }; - - return batch.Execute((ctx, ct) => - { - Excel.Model? model = null; - Excel.ModelTable? table = null; - try - { - // Check if workbook has Data Model - if (!HasDataModelTables(ctx.Book)) - { - throw new InvalidOperationException(DataModelErrorMessages.NoDataModelTables()); - } - - model = ctx.Book.Model; - - // Find the table - table = FindModelTable(model!, tableName); - if (table == null) - { - throw new InvalidOperationException(DataModelErrorMessages.TableNotFound(tableName)); - } - - // Iterate through columns - ComUtilities.ForEachColumn(table, (column, index) => - { - bool isCalculated = false; - try - { - // PIA gap: ModelTableColumn.IsCalculatedColumn not in Excel PIA v16; access via dynamic - isCalculated = ((dynamic)column).IsCalculatedColumn ?? false; - } - catch (Exception ex) when (ex is Microsoft.CSharp.RuntimeBinder.RuntimeBinderException - or System.Runtime.InteropServices.COMException) - { - // Ignore - property not available in this Excel version - isCalculated = false; - } - - var columnInfo = new DataModelColumnInfo - { - Name = ComUtilities.SafeGetString(column, "Name"), - DataType = ComUtilities.SafeGetString(column, "DataType"), - IsCalculated = isCalculated - }; - - result.Columns.Add(columnInfo); - }); - - result.Success = true; - } - finally - { - ComUtilities.Release(ref table); - ComUtilities.Release(ref model); - } - - return result; - }); - } - - /// <inheritdoc /> - public DataModelTableViewResult ReadTable(IExcelBatch batch, string tableName) - { - var result = new DataModelTableViewResult - { - FilePath = batch.WorkbookPath, - TableName = tableName - }; - - return batch.Execute((ctx, ct) => - { - Excel.Model? model = null; - Excel.ModelTable? table = null; - try - { - // Check if workbook has Data Model - if (!HasDataModelTables(ctx.Book)) - { - throw new InvalidOperationException(DataModelErrorMessages.NoDataModelTables()); - } - - model = ctx.Book.Model; - - // Find the table - table = FindModelTable(model!, tableName); - if (table == null) - { - throw new InvalidOperationException(DataModelErrorMessages.TableNotFound(tableName)); - } - - // Get table properties - result.SourceName = ComUtilities.SafeGetString(table, "SourceName"); - result.RecordCount = ComUtilities.SafeGetInt(table, "RecordCount"); - - // Get columns - ComUtilities.ForEachColumn(table, (column, index) => - { - bool isCalculated = false; - try - { - // PIA gap: ModelTableColumn.IsCalculatedColumn not in Excel PIA v16; access via dynamic - isCalculated = ((dynamic)column).IsCalculatedColumn ?? false; - } - catch (Exception ex) when (ex is Microsoft.CSharp.RuntimeBinder.RuntimeBinderException - or System.Runtime.InteropServices.COMException) - { - // Ignore - property not available in this Excel version - isCalculated = false; - } - - var columnInfo = new DataModelColumnInfo - { - Name = ComUtilities.SafeGetString(column, "Name"), - DataType = ComUtilities.SafeGetString(column, "DataType"), - IsCalculated = isCalculated - }; - - result.Columns.Add(columnInfo); - }); - - // Count measures in this table - result.MeasureCount = 0; - ForEachMeasure(model!, (measure, index) => - { - string measureTableName = ComUtilities.SafeGetString(measure.AssociatedTable, "Name"); - if (string.Equals(measureTableName, tableName, StringComparison.OrdinalIgnoreCase)) - { - result.MeasureCount++; - } - }); - - result.Success = true; - } - finally - { - ComUtilities.Release(ref table); - ComUtilities.Release(ref model); - } - - return result; - }); - } - - /// <inheritdoc /> - public DataModelInfoResult ReadInfo(IExcelBatch batch) - { - var result = new DataModelInfoResult { FilePath = batch.WorkbookPath }; - - using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); - - return batch.Execute((ctx, ct) => - { - Excel.Model? model = null; - try - { - // Check if workbook has Data Model - if (!HasDataModelTables(ctx.Book)) - { - throw new InvalidOperationException(DataModelErrorMessages.NoDataModelTables()); - } - - model = ctx.Book.Model; - - // Count tables and sum rows - int totalRows = 0; - ForEachTable(model!, (table, index) => - { - result.TableCount++; - totalRows += ComUtilities.SafeGetInt(table, "RecordCount"); - result.TableNames.Add(ComUtilities.SafeGetString(table, "Name")); - }); - result.TotalRows = totalRows; - - // Count measures - ForEachMeasure(model!, (measure, index) => - { - result.MeasureCount++; - }); - - // Count relationships - ForEachRelationship(model!, (relationship, index) => - { - result.RelationshipCount++; - }); - - result.Success = true; - } - finally - { - ComUtilities.Release(ref model); - } - - return result; - }, timeoutCts.Token); - } -} - - - diff --git a/src/ExcelMcp.Core/Commands/DataModel/DataModelCommands.Refresh.cs b/src/ExcelMcp.Core/Commands/DataModel/DataModelCommands.Refresh.cs deleted file mode 100644 index 8ed297bd..00000000 --- a/src/ExcelMcp.Core/Commands/DataModel/DataModelCommands.Refresh.cs +++ /dev/null @@ -1,106 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.DataModel; -using Sbroenne.ExcelMcp.Core.Models; -using Excel = Microsoft.Office.Interop.Excel; - - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Data Model Refresh operations -/// </summary> -public partial class DataModelCommands -{ - /// <inheritdoc /> - public OperationResult Refresh(IExcelBatch batch, string? tableName = null, TimeSpan? timeout = null) - { - var effectiveTimeout = timeout.HasValue && timeout.Value > TimeSpan.Zero - ? timeout.Value - : TimeSpan.FromMinutes(2); - using var timeoutCts = new CancellationTokenSource(effectiveTimeout); - - try - { - return batch.Execute((ctx, ct) => - { - Excel.Model? model = null; - try - { - // Check if workbook has Data Model - if (!HasDataModelTables(ctx.Book)) - { - throw new InvalidOperationException(DataModelErrorMessages.NoDataModelTables()); - } - - model = ctx.Book.Model; - - if (tableName != null) - { - // Refresh specific table - Excel.ModelTable? table = FindModelTable(model!, tableName); - if (table == null) - { - throw new InvalidOperationException(DataModelErrorMessages.TableNotFound(tableName)); - } - - try - { - OleMessageFilter.EnterLongOperation(); - try - { - table.Refresh(); - } - finally - { - OleMessageFilter.ExitLongOperation(); - } - } - finally - { - ComUtilities.Release(ref table); - } - } - else - { - // Refresh entire model - OleMessageFilter.EnterLongOperation(); - try - { - model.Refresh(); - } - catch (Exception refreshEx) - { - // Model.Refresh() may not be supported in all Excel versions - // Fall back to refreshing tables individually - throw new InvalidOperationException($"Model-level refresh not supported. Try refreshing tables individually. Error: {refreshEx.Message}", refreshEx); - } - finally - { - OleMessageFilter.ExitLongOperation(); - } - } - - // NOTE: CUBEVALUE formulas may still show #N/A after refresh. - // Application.Calculate() and CalculateFull() can throw COM errors (0x800AC472). - // This is a known Excel COM limitation - CUBE functions require interactive Excel. - // See: https://github.com/sbroenne/mcp-server-excel/issues/313 - } - finally - { - ComUtilities.Release(ref model); - } - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - }, timeoutCts.Token); - } - catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) - { - throw new TimeoutException( - $"Data Model refresh timed out after {effectiveTimeout.TotalSeconds:F0} seconds for '{Path.GetFileName(batch.WorkbookPath)}'."); - } - } -} - - - diff --git a/src/ExcelMcp.Core/Commands/DataModel/DataModelCommands.RenameTable.cs b/src/ExcelMcp.Core/Commands/DataModel/DataModelCommands.RenameTable.cs deleted file mode 100644 index 2f5aed30..00000000 --- a/src/ExcelMcp.Core/Commands/DataModel/DataModelCommands.RenameTable.cs +++ /dev/null @@ -1,267 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.DataModel; -using Sbroenne.ExcelMcp.Core.Models; -using Excel = Microsoft.Office.Interop.Excel; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Data Model Table Rename operation. -/// Implements COM-first approach with Power Query fallback for PQ-backed tables. -/// </summary> -public partial class DataModelCommands -{ - /// <inheritdoc /> - public RenameResult RenameTable(IExcelBatch batch, string oldName, string newName) - { - return batch.Execute((ctx, _) => - { - var result = new RenameResult - { - ObjectType = "data-model-table", - OldName = oldName, - NewName = newName, - NormalizedOldName = RenameNameRules.Normalize(oldName), - NormalizedNewName = RenameNameRules.Normalize(newName) - }; - - // Validate old name is not empty - if (RenameNameRules.IsEmpty(result.NormalizedOldName)) - { - result.Success = false; - result.ErrorMessage = "Old table name cannot be empty or whitespace."; - return result; - } - - // Validate new name is not empty - if (RenameNameRules.IsEmpty(result.NormalizedNewName)) - { - result.Success = false; - result.ErrorMessage = "New table name cannot be empty or whitespace."; - return result; - } - - // No-op when normalized names are exactly equal - if (RenameNameRules.IsNoOp(result.NormalizedOldName, result.NormalizedNewName)) - { - result.Success = true; - return result; - } - - // Check if workbook has Data Model - if (!HasDataModelTables(ctx.Book)) - { - result.Success = false; - result.ErrorMessage = DataModelErrorMessages.NoDataModelTables(); - return result; - } - - Excel.Model? model = null; - Excel.ModelTable? table = null; - Excel.WorkbookConnection? sourceConnection = null; - try - { - model = ctx.Book.Model; - - // Find target table (case-insensitive lookup per FindModelTable) - table = FindModelTable(model!, result.NormalizedOldName); - if (table == null) - { - result.Success = false; - result.ErrorMessage = DataModelErrorMessages.TableNotFound(result.NormalizedOldName); - return result; - } - - // Collect existing table names for conflict detection - var existingNames = new List<string>(); - ForEachTable(model!, (t, _) => - { - existingNames.Add(ComUtilities.SafeGetString(t, "Name")); - }); - - // Check for conflicts (case-insensitive, excluding target) - if (RenameNameRules.HasConflict(existingNames, result.NormalizedNewName, result.NormalizedOldName)) - { - result.Success = false; - result.ErrorMessage = $"A table named '{result.NormalizedNewName}' already exists (case-insensitive match)."; - return result; - } - - // ModelTable.Name is read-only per Microsoft documentation. - // Direct rename is not possible - must use Power Query rename for PQ-backed tables. - // See: https://learn.microsoft.com/en-us/office/vba/excel/concepts/about-the-powerpivot-model-object-in-excel - - // Get the source connection to check if this is a PQ-backed table - sourceConnection = table.SourceWorkbookConnection; - if (sourceConnection == null) - { - result.Success = false; - result.ErrorMessage = $"Cannot rename table '{result.NormalizedOldName}': " + - "Direct rename is not supported and table has no source connection. " + - "Only Power Query-backed Data Model tables can be renamed."; - return result; - } - - // Check if this is a Power Query connection - if (!PowerQuery.PowerQueryHelpers.IsPowerQueryConnection(sourceConnection)) - { - result.Success = false; - result.ErrorMessage = $"Cannot rename table '{result.NormalizedOldName}': " + - "Direct rename is not supported. Table is not backed by Power Query. " + - "Only Power Query-backed Data Model tables can be renamed."; - return result; - } - - // Extract Power Query name from connection (format: "Query - {QueryName}") - string connectionName = sourceConnection.Name?.ToString() ?? string.Empty; - if (!connectionName.StartsWith("Query - ", StringComparison.OrdinalIgnoreCase)) - { - result.Success = false; - result.ErrorMessage = $"Cannot rename table '{result.NormalizedOldName}': " + - "Power Query connection name format is unexpected: '{connectionName}'."; - return result; - } - - string pqName = connectionName["Query - ".Length..]; - - // Find and rename the underlying Power Query - Excel.WorkbookQuery? targetQuery = null; - dynamic? oleDbConnection = null; - try - { - targetQuery = ComUtilities.FindQuery(ctx.Book, pqName); - if (targetQuery == null) - { - result.Success = false; - result.ErrorMessage = $"Cannot rename table '{result.NormalizedOldName}': " + - $"Associated Power Query '{pqName}' not found."; - return result; - } - - // Step 1: Rename the Power Query - targetQuery.Name = result.NormalizedNewName; - - // Step 2: Update the connection name to match the new query name - // Connection name format: "Query - {QueryName}" - string newConnectionName = $"Query - {result.NormalizedNewName}"; - sourceConnection.Name = newConnectionName; - - // Step 3: Update the connection string to reference the new query name - // Connection string format: "OLEDB;Provider=Microsoft.Mashup.OleDb.1;Data Source=$Workbook$;Location={QueryName}" - oleDbConnection = sourceConnection.OLEDBConnection; - if (oleDbConnection != null) - { - string? currentConnectionString = oleDbConnection.Connection?.ToString(); - if (!string.IsNullOrEmpty(currentConnectionString)) - { - // Replace Location={oldName} with Location={newName} - // Handle both exact match and partial match scenarios - string oldLocation = $"Location={pqName}"; - string newLocation = $"Location={result.NormalizedNewName}"; - - if (currentConnectionString.Contains(oldLocation, StringComparison.OrdinalIgnoreCase)) - { - string newConnectionString = currentConnectionString.Replace( - oldLocation, - newLocation, - StringComparison.OrdinalIgnoreCase); - oleDbConnection.Connection = newConnectionString; - } - } - } - - // Step 4: Refresh the Data Model to attempt table name update - // ModelTable.Name is read-only and cached from the connection at creation time. - // Refreshing the model DOES NOT update the table name - this is a known Excel limitation. - try - { - model!.Refresh(); - } -#pragma warning disable CA1031 // Catch more specific exception - Model.Refresh() can throw many COM exception types - catch (Exception) - { - // Model refresh may fail for various reasons (data source issues, etc.) - // This is best-effort and not critical to the operation - } -#pragma warning restore CA1031 - - // Step 5: Verify the table name actually updated using CASE-SENSITIVE comparison - // Excel's Data Model table names are immutable after creation. - // Even though we renamed the Power Query and connection, the ModelTable.Name - // remains cached at its original value. This is an Excel/COM API limitation. - // - // Note: FindModelTable uses case-insensitive lookup, so we must re-check the - // actual table name returned to confirm the rename truly succeeded. - ComUtilities.Release(ref table!); - table = FindModelTable(model!, result.NormalizedNewName); - - if (table != null) - { - // Table found - but verify the name matches EXACTLY (case-sensitive) - // FindModelTable uses case-insensitive lookup, so "testtable" would match "TestTable" - string actualName = ComUtilities.SafeGetString(table, "Name"); - if (string.Equals(actualName, result.NormalizedNewName, StringComparison.Ordinal)) - { - // Table name matches exactly - rename succeeded - result.Success = true; - return result; - } - // Table found but name doesn't match exactly - rename failed - } - - // Table not found with new name - rename failed due to Excel limitation - // Rollback the Power Query and connection names to maintain consistency - try - { - targetQuery.Name = pqName; // Restore original PQ name - sourceConnection.Name = connectionName; // Restore original connection name - if (oleDbConnection != null) - { - string? currentConnectionString = oleDbConnection.Connection?.ToString(); - if (!string.IsNullOrEmpty(currentConnectionString)) - { - string newLocation = $"Location={result.NormalizedNewName}"; - string oldLocation = $"Location={pqName}"; - if (currentConnectionString.Contains(newLocation, StringComparison.OrdinalIgnoreCase)) - { - string restoredConnectionString = currentConnectionString.Replace( - newLocation, - oldLocation, - StringComparison.OrdinalIgnoreCase); - oleDbConnection.Connection = restoredConnectionString; - } - } - } - } -#pragma warning disable CA1031 // Catch more specific exception - Rollback is best-effort, must not throw - catch (Exception) - { - // Rollback failed - best effort cleanup, cannot propagate - } -#pragma warning restore CA1031 - - result.Success = false; - result.ErrorMessage = $"Cannot rename Data Model table '{result.NormalizedOldName}': " + - "Excel's Data Model table names are immutable after creation. " + - "The underlying Power Query and connection were temporarily renamed but have been rolled back. " + - "To rename a Data Model table, you must delete it and recreate it with the new name."; - return result; - } - finally - { - ComUtilities.Release(ref oleDbConnection!); - ComUtilities.Release(ref targetQuery!); - } - } - finally - { - ComUtilities.Release(ref sourceConnection!); - ComUtilities.Release(ref table!); - ComUtilities.Release(ref model!); - } - }); - } -} - - diff --git a/src/ExcelMcp.Core/Commands/DataModel/DataModelCommands.Write.cs b/src/ExcelMcp.Core/Commands/DataModel/DataModelCommands.Write.cs deleted file mode 100644 index 1080df9c..00000000 --- a/src/ExcelMcp.Core/Commands/DataModel/DataModelCommands.Write.cs +++ /dev/null @@ -1,534 +0,0 @@ -using Polly; -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Formatting; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.DataModel; -using Sbroenne.ExcelMcp.Core.Models; -using Excel = Microsoft.Office.Interop.Excel; - - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Data Model Write operations - Delete, Create, Update -/// Includes resilient retry logic for intermittent 0x800AC472 errors. -/// </summary> -public partial class DataModelCommands -{ - // Resilience pipeline for Data Model operations - handles 0x800AC472 intermittent errors - // See GitHub Issue #315: https://github.com/sbroenne/mcp-server-excel/issues/315 - private static readonly ResiliencePipeline _dataModelPipeline = ResiliencePipelines.CreateDataModelPipeline(); - - /// <inheritdoc /> - public OperationResult DeleteMeasure(IExcelBatch batch, string measureName) - { - return ExecuteWithRetry(() => - { - return batch.Execute((ctx, ct) => - { - Excel.Model? model = null; - Excel.ModelMeasure? measure = null; - try - { - // Check if workbook has Data Model - if (!HasDataModelTables(ctx.Book)) - { - throw new InvalidOperationException(DataModelErrorMessages.NoDataModelTables()); - } - - model = ctx.Book.Model; - - // Find the measure - measure = FindModelMeasure(model!, measureName); - if (measure == null) - { - throw new InvalidOperationException(DataModelErrorMessages.MeasureNotFound(measureName)); - } - - // Delete the measure - measure.Delete(); - } - finally - { - ComUtilities.Release(ref measure); - ComUtilities.Release(ref model); - } - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - }); - }); - } - - /// <inheritdoc /> - public OperationResult DeleteTable(IExcelBatch batch, string tableName) - { - return ExecuteWithRetry(() => - { - return batch.Execute((ctx, ct) => - { - Excel.Model? model = null; - Excel.ModelTable? table = null; - Excel.WorkbookConnection? sourceConnection = null; - try - { - // Check if workbook has Data Model - if (!HasDataModelTables(ctx.Book)) - { - throw new InvalidOperationException(DataModelErrorMessages.NoDataModelTables()); - } - - model = ctx.Book.Model; - - // Find the table - table = FindModelTable(model!, tableName); - if (table == null) - { - throw new InvalidOperationException(DataModelErrorMessages.TableNotFound(tableName)); - } - - // IMPORTANT: ModelTable is read-only and cannot be deleted directly! - // The correct way to delete a Data Model table is to delete its - // SourceWorkbookConnection. When the connection is deleted, - // the associated ModelTable is automatically removed. - // Reference: https://learn.microsoft.com/en-us/office/vba/api/excel.modeltable - // Reference: https://learn.microsoft.com/en-us/office/vba/api/excel.workbookconnection.delete - sourceConnection = table.SourceWorkbookConnection; - if (sourceConnection == null) - { - throw new InvalidOperationException( - $"Table '{tableName}' does not have an associated connection and cannot be deleted. " + - "This may indicate the table was created through an unsupported method."); - } - - // Delete the connection, which removes the associated Data Model table - sourceConnection.Delete(); - } - finally - { - ComUtilities.Release(ref sourceConnection); - ComUtilities.Release(ref table); - ComUtilities.Release(ref model); - } - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - }); - }); - } - - /// <inheritdoc /> - public OperationResult DeleteRelationship(IExcelBatch batch, string fromTable, string fromColumn, string toTable, string toColumn) - { - return ExecuteWithRetry(() => - { - return batch.Execute((ctx, ct) => - { - Excel.Model? model = null; - Excel.ModelRelationships? modelRelationships = null; - try - { - // Check if workbook has Data Model - if (!HasDataModelTables(ctx.Book)) - { - throw new InvalidOperationException(DataModelErrorMessages.NoDataModelTables()); - } - - model = ctx.Book.Model; - modelRelationships = model!.ModelRelationships; - - // Find and delete the relationship - bool found = false; - int count = modelRelationships.Count; - for (int i = 1; i <= count; i++) - { - Excel.ModelRelationship? currentRelationship = null; - try - { - currentRelationship = modelRelationships.Item(i); - - Excel.ModelTableColumn? fkColumn = currentRelationship.ForeignKeyColumn; - Excel.ModelTableColumn? pkColumn = currentRelationship.PrimaryKeyColumn; - - try - { - Excel.ModelTable? fkTable = fkColumn?.Parent as Excel.ModelTable; - Excel.ModelTable? pkTable = pkColumn?.Parent as Excel.ModelTable; - - string currentFromTable = ComUtilities.SafeGetString(fkTable, "Name"); - string currentFromColumn = ComUtilities.SafeGetString(fkColumn, "Name"); - string currentToTable = ComUtilities.SafeGetString(pkTable, "Name"); - string currentToColumn = ComUtilities.SafeGetString(pkColumn, "Name"); - - ComUtilities.Release(ref fkTable); - ComUtilities.Release(ref pkTable); - - if (currentFromTable.Equals(fromTable, StringComparison.OrdinalIgnoreCase) && - currentFromColumn.Equals(fromColumn, StringComparison.OrdinalIgnoreCase) && - currentToTable.Equals(toTable, StringComparison.OrdinalIgnoreCase) && - currentToColumn.Equals(toColumn, StringComparison.OrdinalIgnoreCase)) - { - // Delete the relationship - currentRelationship.Delete(); - found = true; - break; - } - } - finally - { - ComUtilities.Release(ref fkColumn); - ComUtilities.Release(ref pkColumn); - } - } - finally - { - ComUtilities.Release(ref currentRelationship); - } - } - - if (!found) - { - throw new InvalidOperationException(DataModelErrorMessages.RelationshipNotFound(fromTable, fromColumn, toTable, toColumn)); - } - } - finally - { - ComUtilities.Release(ref modelRelationships); - ComUtilities.Release(ref model); - } - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - }); - }); - } - - /// <inheritdoc /> - public OperationResult CreateMeasure(IExcelBatch batch, string tableName, string measureName, - string daxFormula, string? formatType = null, - string? description = null) - { - // Format DAX before saving (outside ExecuteWithRetry for async operation) - // Formatting is done synchronously to maintain method signature compatibility - // Falls back to original if formatting fails - string formattedDax = DaxFormatter.FormatAsync(daxFormula).GetAwaiter().GetResult(); - - return ExecuteWithRetry(() => - { - return batch.Execute((ctx, ct) => - { - Excel.Model? model = null; - Excel.ModelTable? table = null; - Excel.ModelMeasures? measures = null; - Excel.ModelMeasure? newMeasure = null; - object? formatObject = null; - try - { - // Check if workbook has Data Model - if (!HasDataModelTables(ctx.Book)) - { - throw new InvalidOperationException(DataModelErrorMessages.NoDataModelTables()); - } - - model = ctx.Book.Model; - - // Find the table - table = FindModelTable(model!, tableName); - if (table == null) - { - throw new InvalidOperationException(DataModelErrorMessages.TableNotFound(tableName)); - } - - // Check if measure already exists - Excel.ModelMeasure? existingMeasure = FindModelMeasure(model!, measureName); - if (existingMeasure != null) - { - ComUtilities.Release(ref existingMeasure); - throw new InvalidOperationException($"Measure '{measureName}' already exists in the Data Model"); - } - - // Translate DAX formula separators from US format (comma) to locale-specific format - // This fixes issues on European locales where semicolon is the list separator - // Example: DATEADD(Date[Date], -1, MONTH) → DATEADD(Date[Date]; -1; MONTH) on German Excel - // NOTE: Translation is done on the FORMATTED DAX - var daxTranslator = new DaxFormulaTranslator(ctx.App); - string localizedFormula = daxTranslator.TranslateToLocale(formattedDax); - - // Get ModelMeasures collection from MODEL (not from table!) - // Reference: https://learn.microsoft.com/en-us/office/vba/api/excel.model.modelmeasures - measures = model!.ModelMeasures; - - // Get format object - ALWAYS returns a valid format object (never null) - // Fixed: Always provide format object to avoid failures on reopened Data Model files - formatObject = GetFormatObject(model!, formatType); - - // Create the measure using Excel COM API (Office 2016+) - // Reference: https://learn.microsoft.com/en-us/office/vba/api/excel.modelmeasures.add - // FIXED: FormatInformation is REQUIRED (not optional as docs state) - // See: docs/KNOWN-ISSUES.md for details - newMeasure = measures.Add( - measureName, // MeasureName (required) - table!, // AssociatedTable (required) - localizedFormula, // Formula (required) - must be valid DAX, formatted and translated for locale - formatObject!, // FormatInformation (required) - NEVER null/Type.Missing - string.IsNullOrEmpty(description) ? Type.Missing : description // Description (optional) - ); - } - finally - { - // Note: formatObject is a property reference from the model (not a new object) - // Do NOT release formatObject - it's owned by the model - ComUtilities.Release(ref newMeasure); - ComUtilities.Release(ref measures); - ComUtilities.Release(ref table); - ComUtilities.Release(ref model); - } - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - }); - }); - } - - /// <inheritdoc /> - public OperationResult UpdateMeasure(IExcelBatch batch, string measureName, - string? daxFormula = null, string? formatType = null, - string? description = null) - { - // Format DAX before saving (outside ExecuteWithRetry for async operation) - // Only format if daxFormula is provided - // Formatting is done synchronously to maintain method signature compatibility - // Falls back to original if formatting fails - string? formattedDax = null; - if (!string.IsNullOrEmpty(daxFormula)) - { - formattedDax = DaxFormatter.FormatAsync(daxFormula).GetAwaiter().GetResult(); - } - - return ExecuteWithRetry(() => - { - return batch.Execute((ctx, ct) => - { - Excel.Model? model = null; - Excel.ModelMeasure? measure = null; - object? formatObject = null; - try - { - // Check if workbook has Data Model - if (!HasDataModelTables(ctx.Book)) - { - throw new InvalidOperationException(DataModelErrorMessages.NoDataModelTables()); - } - - model = ctx.Book.Model; - - // Find the measure - measure = FindModelMeasure(model!, measureName); - if (measure == null) - { - throw new InvalidOperationException(DataModelErrorMessages.MeasureNotFound(measureName)); - } - - var updates = new List<string>(); - - // Update formula if provided - // Reference: https://learn.microsoft.com/en-us/office/vba/api/excel.modelmeasure (Formula property is Read/Write) - if (!string.IsNullOrEmpty(formattedDax)) - { - // Translate DAX formula separators from US format (comma) to locale-specific format - // This fixes issues on European locales where semicolon is the list separator - // NOTE: Translation is done on the FORMATTED DAX - var daxTranslator = new DaxFormulaTranslator(ctx.App); - string localizedFormula = daxTranslator.TranslateToLocale(formattedDax); - measure.Formula = localizedFormula; - updates.Add("Formula updated"); - } - - // Update format if provided - if (!string.IsNullOrEmpty(formatType)) - { - formatObject = GetFormatObject(model!, formatType); - if (formatObject != null) - { - measure.FormatInformation = formatObject; - updates.Add($"Format changed to {formatType}"); - } - } - - // Update description if provided - // Reference: https://learn.microsoft.com/en-us/office/vba/api/excel.modelmeasure (Description property is Read/Write) - if (description != null) - { - measure.Description = description; - updates.Add("Description updated"); - } - - if (updates.Count == 0) - { - throw new ArgumentException("No updates provided. Specify at least one of: daxFormula, formatType, or description"); - } - } - finally - { - // Note: formatObject is a property reference from the model (not a new object) - // Do NOT release formatObject - it's owned by the model - ComUtilities.Release(ref measure); - ComUtilities.Release(ref model); - } - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - }); - }); - } - - /// <inheritdoc /> - public OperationResult CreateRelationship(IExcelBatch batch, string fromTable, - string fromColumn, string toTable, - string toColumn, bool active = true) - { - return ExecuteWithRetry(() => - { - return batch.Execute((ctx, ct) => - { - Excel.Model? model = null; - Excel.ModelRelationships? relationships = null; - Excel.ModelTable? fromTableObj = null; - Excel.ModelTable? toTableObj = null; - Excel.ModelTableColumn? fromColumnObj = null; - Excel.ModelTableColumn? toColumnObj = null; - Excel.ModelRelationship? newRelationship = null; - try - { - // Check if workbook has Data Model - if (!HasDataModelTables(ctx.Book)) - { - throw new InvalidOperationException(DataModelErrorMessages.NoDataModelTables()); - } - - model = ctx.Book.Model; - - // Find source table and column - fromTableObj = FindModelTable(model!, fromTable); - if (fromTableObj == null) - { - throw new InvalidOperationException(DataModelErrorMessages.TableNotFound(fromTable)); - } - - fromColumnObj = FindModelTableColumn(fromTableObj, fromColumn); - if (fromColumnObj == null) - { - throw new InvalidOperationException($"Column '{fromColumn}' not found in table '{fromTable}'"); - } - - // Find target table and column - toTableObj = FindModelTable(model!, toTable); - if (toTableObj == null) - { - throw new InvalidOperationException(DataModelErrorMessages.TableNotFound(toTable)); - } - - toColumnObj = FindModelTableColumn(toTableObj, toColumn); - if (toColumnObj == null) - { - throw new InvalidOperationException($"Column '{toColumn}' not found in table '{toTable}'"); - } - - // Check if relationship already exists - Excel.ModelRelationship? existingRel = FindRelationship(model!, fromTable, fromColumn, toTable, toColumn); - if (existingRel != null) - { - ComUtilities.Release(ref existingRel); - throw new InvalidOperationException($"Relationship from {fromTable}.{fromColumn} to {toTable}.{toColumn} already exists"); - } - - // Create the relationship using Excel COM API (Office 2016+) - // Reference: https://learn.microsoft.com/en-us/office/vba/api/excel.modelrelationships.add - relationships = model!.ModelRelationships; - newRelationship = relationships.Add( - ForeignKeyColumn: fromColumnObj!, - PrimaryKeyColumn: toColumnObj! - ); - - // Set active state - // Reference: https://learn.microsoft.com/en-us/office/vba/api/excel.modelrelationship (Active property is Read/Write) - newRelationship!.Active = active; - } - finally - { - ComUtilities.Release(ref newRelationship); - ComUtilities.Release(ref toColumnObj); - ComUtilities.Release(ref fromColumnObj); - ComUtilities.Release(ref toTableObj); - ComUtilities.Release(ref fromTableObj); - ComUtilities.Release(ref relationships); - ComUtilities.Release(ref model); - } - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - }); - }); - } - - /// <inheritdoc /> - public OperationResult UpdateRelationship(IExcelBatch batch, string fromTable, - string fromColumn, string toTable, - string toColumn, bool active) - { - return ExecuteWithRetry(() => - { - return batch.Execute((ctx, ct) => - { - Excel.Model? model = null; - Excel.ModelRelationship? relationship = null; - try - { - // Check if workbook has Data Model - if (!HasDataModelTables(ctx.Book)) - { - throw new InvalidOperationException(DataModelErrorMessages.NoDataModelTables()); - } - - model = ctx.Book.Model; - - // Find the relationship - relationship = FindRelationship(model!, fromTable, fromColumn, toTable, toColumn); - if (relationship == null) - { - throw new InvalidOperationException(DataModelErrorMessages.RelationshipNotFound(fromTable, fromColumn, toTable, toColumn)); - } - - // Update active state - // Reference: https://learn.microsoft.com/en-us/office/vba/api/excel.modelrelationship (Active property is Read/Write) - relationship.Active = active; - } - finally - { - ComUtilities.Release(ref relationship); - ComUtilities.Release(ref model); - } - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - }); - }); - } - - /// <summary> - /// Executes an action with resilient retry logic for intermittent Data Model errors. - /// Handles 0x800AC472 and other transient COM errors with exponential backoff. - /// </summary> - /// <param name="action">The action to execute with retry</param> - private static void ExecuteWithRetry(Action action) - { - _dataModelPipeline.Execute(action); - } - - /// <summary> - /// Executes a function with resilient retry logic for intermittent Data Model errors. - /// Returns the result of the function. - /// </summary> - /// <typeparam name="T">The return type</typeparam> - /// <param name="func">The function to execute with retry</param> - private static T ExecuteWithRetry<T>(Func<T> func) - { - return _dataModelPipeline.Execute(func); - } -} - - - diff --git a/src/ExcelMcp.Core/Commands/DataModel/DataModelCommands.cs b/src/ExcelMcp.Core/Commands/DataModel/DataModelCommands.cs deleted file mode 100644 index 08531af9..00000000 --- a/src/ExcelMcp.Core/Commands/DataModel/DataModelCommands.cs +++ /dev/null @@ -1,21 +0,0 @@ - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Data Model management commands - Core data layer (no console output) -/// Provides access to Excel Data Model (PowerPivot) objects: tables, measures, relationships. -/// Split into partial classes: Base (constructor), Read (List/View/Export), Write (Delete/Create/Update), Refresh -/// Implements both IDataModelCommands (tables/measures) and IDataModelRelCommands (relationships). -/// </summary> -public partial class DataModelCommands : IDataModelCommands, IDataModelRelCommands -{ - /// <summary> - /// Constructor for DataModelCommands - /// </summary> - public DataModelCommands() - { - // No dependencies currently needed - } -} - - diff --git a/src/ExcelMcp.Core/Commands/DataModel/DataModelErrorMessages.cs b/src/ExcelMcp.Core/Commands/DataModel/DataModelErrorMessages.cs deleted file mode 100644 index 334a9d3f..00000000 --- a/src/ExcelMcp.Core/Commands/DataModel/DataModelErrorMessages.cs +++ /dev/null @@ -1,73 +0,0 @@ -namespace Sbroenne.ExcelMcp.Core.DataModel; - -/// <summary> -/// Standardized error messages for Data Model operations -/// </summary> -public static class DataModelErrorMessages -{ - /// <summary> - /// Error message when Data Model has no tables - /// NOTE: Every workbook has a Model object, but it may be empty (no tables) - /// </summary> - public static string NoDataModelTables() - { - return "Data Model has no tables. Add a table to the Data Model first using 'table-add-to-datamodel' or load data via Power Query."; - } - - /// <summary> - /// Error message when a table is not found in the Data Model - /// </summary> - public static string TableNotFound(string tableName) - { - return $"Table '{tableName}' not found in Data Model."; - } - - /// <summary> - /// Error message when a measure is not found in the Data Model - /// </summary> - public static string MeasureNotFound(string measureName) - { - return $"Measure '{measureName}' not found in Data Model."; - } - - /// <summary> - /// Error message when a relationship is not found in the Data Model - /// </summary> - public static string RelationshipNotFound(string fromTable, string fromColumn, string toTable, string toColumn) - { - return $"Relationship from '{fromTable}[{fromColumn}]' to '{toTable}[{toColumn}]' not found in Data Model."; - } - - /// <summary> - /// Error message for general operation failures - /// </summary> - public static string OperationFailed(string operation, string details) - { - return $"{operation} failed: {details}"; - } - - /// <summary> - /// Error message when MSOLAP provider is not installed (Class not registered). - /// This occurs when trying to execute DAX queries via ADOConnection. - /// </summary> - public static string MsolapProviderNotInstalled() - { - return "DAX query execution requires the Microsoft Analysis Services OLE DB Provider (MSOLAP), which is not installed. " + - "To fix this, install one of the following:\n" + - " 1. Power BI Desktop (recommended - includes MSOLAP): https://powerbi.microsoft.com/desktop\n" + - " 2. Microsoft OLE DB Driver for Analysis Services: https://learn.microsoft.com/analysis-services/client-libraries\n" + - " 3. SQL Server Analysis Services (SSAS) client tools\n" + - "After installation, restart Excel and try again."; - } - - /// <summary> - /// Error message when ADO connection to Data Model fails - /// </summary> - public static string AdoConnectionFailed(string details) - { - return $"Failed to connect to Data Model for DAX query execution: {details}. " + - "Ensure Power Pivot is enabled in Excel (File > Options > Add-ins > COM Add-ins > Microsoft Power Pivot for Excel)."; - } -} - - diff --git a/src/ExcelMcp.Core/Commands/DataModel/IDataModelCommands.cs b/src/ExcelMcp.Core/Commands/DataModel/IDataModelCommands.cs deleted file mode 100644 index 59add194..00000000 --- a/src/ExcelMcp.Core/Commands/DataModel/IDataModelCommands.cs +++ /dev/null @@ -1,218 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Attributes; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Data Model (Power Pivot) - DAX measures and table management. -/// -/// CRITICAL: WORKSHEET TABLES AND DATA MODEL ARE SEPARATE! -/// - After table append changes, Data Model still has OLD data -/// - MUST call refresh to sync changes -/// - Power Query refresh auto-syncs (no manual refresh needed) -/// -/// PREREQUISITE: Tables must be added to the Data Model first. -/// Use table add-to-datamodel for worksheet tables, -/// or powerquery to import and load data directly to the Data Model. -/// -/// DAX MEASURES: -/// - Create with DAX formulas like 'SUM(Sales[Amount])' -/// - DAX formulas are auto-formatted on CREATE/UPDATE via Dax.Formatter (SQLBI) -/// - Read operations return raw DAX as stored -/// -/// DAX EVALUATE QUERIES: -/// - Use evaluate to execute DAX EVALUATE queries against the Data Model -/// - Returns tabular results from queries like 'EVALUATE TableName' -/// - Supports complex DAX: SUMMARIZE, FILTER, CALCULATETABLE, TOPN, etc. -/// -/// DMV (DYNAMIC MANAGEMENT VIEW) QUERIES: -/// - Use execute-dmv to query Data Model metadata via SQL-like syntax -/// - Syntax: SELECT * FROM $SYSTEM.SchemaRowset (ONLY SELECT * supported) -/// - Use DISCOVER_SCHEMA_ROWSETS to list all available DMVs -/// -/// Use datamodelrel for relationships between tables. -/// </summary> -[ServiceCategory("datamodel", "DataModel")] -[McpTool("datamodel", Title = "Data Model Operations", Destructive = true, Category = "analysis", - Description = "Data Model (Power Pivot) - DAX measures and table management. CRITICAL: Worksheet tables and Data Model are separate! After table(append), MUST call datamodel(refresh) to sync. Power Query refresh auto-syncs. DAX MEASURES: Create with formulas like SUM(Sales[Amount]), auto-formatted via daxformatter.com. DAX EVALUATE: Execute queries (SUMMARIZE, FILTER, CALCULATETABLE, TOPN). DMV QUERIES: SELECT * FROM $SYSTEM.SchemaRowset for metadata. DAX FILE INPUT: daxFormulaFile/daxQueryFile for complex multi-line DAX. TIMEOUT: 2 min. Use datamodel_relationship for relationships, table for add-to-datamodel.")] -public interface IDataModelCommands -{ - /// <summary> - /// Lists all tables in the Data Model - /// </summary> - /// <param name="batch">Excel batch context for accessing workbook</param> - /// <returns>Result containing list of tables with metadata</returns> - [ServiceAction("list-tables")] - DataModelTableListResult ListTables(IExcelBatch batch); - - /// <summary> - /// Lists all columns in a Data Model table - /// </summary> - /// <param name="batch">Excel batch context for accessing workbook</param> - /// <param name="tableName">Name of the table to list columns from</param> - /// <returns>Result containing list of columns with metadata</returns> - [ServiceAction("list-columns")] - DataModelTableColumnsResult ListColumns(IExcelBatch batch, [RequiredParameter] string tableName); - - /// <summary> - /// Gets complete table details including columns and measures - /// </summary> - /// <param name="batch">Excel batch context for accessing workbook</param> - /// <param name="tableName">Name of the table to get</param> - /// <returns>Result containing complete table information</returns> - [ServiceAction("read-table")] - DataModelTableViewResult ReadTable(IExcelBatch batch, [RequiredParameter] string tableName); - - /// <summary> - /// Gets overall Data Model summary statistics - /// </summary> - /// <param name="batch">Excel batch context for accessing workbook</param> - /// <returns>Result containing model metadata (table count, measure count, etc.)</returns> - [ServiceAction("read-info")] - DataModelInfoResult ReadInfo(IExcelBatch batch); - - /// <summary> - /// Lists all DAX measures in the model. - /// </summary> - /// <param name="batch">Excel batch context for accessing workbook</param> - /// <param name="tableName">Optional: Filter measures by table name</param> - /// <returns>Result containing list of measures with formula previews</returns> - [ServiceAction("list-measures")] - DataModelMeasureListResult ListMeasures(IExcelBatch batch, string? tableName = null); - - /// <summary> - /// Gets complete measure details and DAX formula. - /// Returns the raw DAX formula as stored in the Data Model. - /// </summary> - /// <param name="batch">Excel batch context for accessing workbook</param> - /// <param name="measureName">Name of the measure to get</param> - /// <returns>Result containing complete measure information with DAX formula</returns> - [ServiceAction("read")] - DataModelMeasureViewResult Read(IExcelBatch batch, [RequiredParameter] string measureName); - - /// <summary> - /// Deletes a DAX measure from the Data Model - /// </summary> - /// <param name="batch">Excel batch context for accessing workbook</param> - /// <param name="measureName">Name of the measure to delete</param> - /// <exception cref="ArgumentException">Thrown when measureName is invalid</exception> - /// <exception cref="InvalidOperationException">Thrown when measure not found or deletion fails</exception> - [ServiceAction("delete-measure")] - OperationResult DeleteMeasure(IExcelBatch batch, [RequiredParameter] string measureName); - - /// <summary> - /// Deletes a table from the Data Model. - /// Use this to remove orphaned tables created when Power Query is deleted and recreated - /// with a different name, leaving stale tables in the Data Model. - /// </summary> - /// <param name="batch">Excel batch context for accessing workbook</param> - /// <param name="tableName">Name of the table to delete</param> - /// <exception cref="ArgumentException">Thrown when tableName is invalid</exception> - /// <exception cref="InvalidOperationException">Thrown when table not found or deletion fails</exception> - [ServiceAction("delete-table")] - OperationResult DeleteTable(IExcelBatch batch, [RequiredParameter] string tableName); - - /// <summary> - /// Renames a table in the Data Model. - /// Names are trimmed before comparison; a no-op success is returned when - /// trimmed old and new names match (including case-only change no-op). - /// Case-only renames are allowed if new name differs only in casing. - /// Conflict detection is case-insensitive, excluding the target table. - /// </summary> - /// <param name="batch">Excel batch context for accessing workbook</param> - /// <param name="oldName">Current name of the table</param> - /// <param name="newName">New name for the table</param> - /// <returns>RenameResult with ObjectType="data-model-table"</returns> - [ServiceAction("rename-table")] - RenameResult RenameTable(IExcelBatch batch, [RequiredParameter] string oldName, [RequiredParameter] string newName); - - /// <summary> - /// Refreshes entire Data Model or specific table - /// </summary> - /// <param name="batch">Excel batch context for accessing workbook</param> - /// <param name="tableName">Optional: Specific table to refresh (if null, refreshes entire model)</param> - /// <param name="timeout">Optional: Timeout for the refresh operation</param> - /// <exception cref="InvalidOperationException">Thrown when refresh operation fails</exception> - [ServiceAction("refresh")] - OperationResult Refresh(IExcelBatch batch, string? tableName = null, TimeSpan? timeout = null); - - /// <summary> - /// Creates a new DAX measure in the Data Model. - /// DAX formula is automatically formatted with proper indentation before saving. - /// Uses Excel COM API: ModelMeasures.Add method (Office 2016+) - /// </summary> - /// <param name="batch">Excel batch context for accessing workbook</param> - /// <param name="tableName">Name of the table to add the measure to</param> - /// <param name="measureName">Name of the new measure</param> - /// <param name="daxFormula">DAX formula for the measure (will be auto-formatted)</param> - /// <param name="formatType">Optional: Format type (Currency, Decimal, Percentage, General)</param> - /// <param name="description">Optional: Description of the measure</param> - /// <exception cref="ArgumentException">Thrown when parameters are invalid</exception> - /// <exception cref="InvalidOperationException">Thrown when table not found or creation fails</exception> - [ServiceAction("create-measure")] - OperationResult CreateMeasure( - IExcelBatch batch, - [RequiredParameter] string tableName, - [RequiredParameter] string measureName, - [RequiredParameter, FileOrValue] string daxFormula, - string? formatType = null, - string? description = null); - - /// <summary> - /// Updates an existing DAX measure in the Data Model. - /// DAX formula is automatically formatted with proper indentation before saving. - /// Uses Excel COM API: ModelMeasure properties (Formula, Description, FormatInformation - all Read/Write) - /// </summary> - /// <param name="batch">Excel batch context for accessing workbook</param> - /// <param name="measureName">Name of the measure to update</param> - /// <param name="daxFormula">Optional: New DAX formula (null to keep existing, will be auto-formatted if provided)</param> - /// <param name="formatType">Optional: New format type (null to keep existing)</param> - /// <param name="description">Optional: New description (null to keep existing)</param> - /// <exception cref="ArgumentException">Thrown when measureName is invalid or all parameters are null</exception> - /// <exception cref="InvalidOperationException">Thrown when measure not found or update fails</exception> - [ServiceAction("update-measure")] - OperationResult UpdateMeasure( - IExcelBatch batch, - [RequiredParameter] string measureName, - [FileOrValue] string? daxFormula = null, - string? formatType = null, - string? description = null); - - /// <summary> - /// Executes a DAX EVALUATE query against the Data Model and returns the results. - /// Uses ADOConnection.Execute for direct DAX query execution via MSOLAP provider. - /// The query should start with EVALUATE and return a table result. - /// </summary> - /// <param name="batch">Excel batch context for accessing workbook</param> - /// <param name="daxQuery">DAX EVALUATE query (e.g., "EVALUATE 'TableName'" or "EVALUATE SUMMARIZE(...)")</param> - /// <returns>Result containing column names and data rows from the DAX query</returns> - /// <exception cref="ArgumentException">Thrown when daxQuery is empty</exception> - /// <exception cref="InvalidOperationException">Thrown when workbook has no Data Model or query execution fails</exception> - [ServiceAction("evaluate")] - DaxEvaluateResult Evaluate(IExcelBatch batch, [RequiredParameter, FileOrValue] string daxQuery); - - /// <summary> - /// Executes a DMV (Dynamic Management View) query against the Data Model and returns the results. - /// Uses ADOConnection.Execute for SQL-like DMV query execution via MSOLAP provider. - /// DMV queries retrieve metadata about the Data Model (tables, columns, measures, relationships, etc.). - /// </summary> - /// <param name="batch">Excel batch context for accessing workbook</param> - /// <param name="dmvQuery">DMV query in SQL-like syntax (e.g., "SELECT * FROM $SYSTEM.TMSCHEMA_TABLES")</param> - /// <returns>Result containing column names and data rows from the DMV query</returns> - /// <exception cref="ArgumentException">Thrown when dmvQuery is empty</exception> - /// <exception cref="InvalidOperationException">Thrown when workbook has no Data Model or query execution fails</exception> - /// <remarks> - /// Common DMV queries for Excel PowerPivot: - /// - $SYSTEM.TMSCHEMA_TABLES - List all tables - /// - $SYSTEM.TMSCHEMA_COLUMNS - List all columns - /// - $SYSTEM.TMSCHEMA_MEASURES - List all measures - /// - $SYSTEM.TMSCHEMA_RELATIONSHIPS - List all relationships - /// - $SYSTEM.DISCOVER_CALC_DEPENDENCY - Show calculation dependencies - /// </remarks> - [ServiceAction("execute-dmv")] - DmvQueryResult ExecuteDmv(IExcelBatch batch, [RequiredParameter, FileOrValue] string dmvQuery); -} - - - diff --git a/src/ExcelMcp.Core/Commands/DataModel/IDataModelRelCommands.cs b/src/ExcelMcp.Core/Commands/DataModel/IDataModelRelCommands.cs deleted file mode 100644 index 52707de4..00000000 --- a/src/ExcelMcp.Core/Commands/DataModel/IDataModelRelCommands.cs +++ /dev/null @@ -1,114 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Attributes; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Data Model relationships - link tables for cross-table DAX calculations. -/// -/// CRITICAL: Deleting or recreating tables removes ALL their relationships. -/// Use list-relationships before table operations to backup, -/// then recreate relationships after schema changes. -/// -/// RELATIONSHIP REQUIREMENTS: -/// - Both tables must exist in the Data Model first -/// - Columns must have compatible data types -/// - fromTable/fromColumn = many-side (detail table, foreign key) -/// - toTable/toColumn = one-side (lookup table, primary key) -/// -/// ACTIVE VS INACTIVE: -/// - Only ONE active relationship can exist between two tables -/// - Use active=false when creating alternative paths -/// - DAX USERELATIONSHIP() activates inactive relationships -/// </summary> -[ServiceCategory("datamodelrel", "DataModelRel")] -[McpTool("datamodel_relationship", Title = "Data Model Relationship Operations", Destructive = true, Category = "analysis", - Description = "Data Model relationships - link tables for cross-table DAX calculations. CRITICAL: Deleting/recreating tables removes ALL their relationships. Use list before table operations to backup. REQUIREMENTS: Both tables in Data Model, compatible column types. From=many-side (detail), To=one-side (lookup). ACTIVE VS INACTIVE: One active relationship per table pair. Use DAX USERELATIONSHIP() for inactive. TIMEOUT: 2 min. Use datamodel for tables and DAX measures.")] -public interface IDataModelRelCommands -{ - /// <summary> - /// Lists all table relationships in the model - /// </summary> - /// <param name="batch">Excel batch context for accessing workbook</param> - /// <returns>Result containing list of relationships</returns> - [ServiceAction("list-relationships")] - DataModelRelationshipListResult ListRelationships(IExcelBatch batch); - - /// <summary> - /// Gets a specific relationship by its table/column identifiers - /// </summary> - /// <param name="batch">Excel batch context for accessing workbook</param> - /// <param name="fromTable">Source table name</param> - /// <param name="fromColumn">Source column name</param> - /// <param name="toTable">Target table name</param> - /// <param name="toColumn">Target column name</param> - /// <returns>Result containing relationship details</returns> - [ServiceAction("read-relationship")] - DataModelRelationshipViewResult ReadRelationship( - IExcelBatch batch, - [RequiredParameter] string fromTable, - [RequiredParameter] string fromColumn, - [RequiredParameter] string toTable, - [RequiredParameter] string toColumn); - - /// <summary> - /// Creates a new relationship between two tables in the Data Model. - /// Uses Excel COM API: ModelRelationships.Add method (Office 2016+) - /// </summary> - /// <param name="batch">Excel batch context for accessing workbook</param> - /// <param name="fromTable">Source table name</param> - /// <param name="fromColumn">Source column name</param> - /// <param name="toTable">Target table name</param> - /// <param name="toColumn">Target column name</param> - /// <param name="active">Whether the relationship should be active (default: true)</param> - /// <exception cref="ArgumentException">Thrown when parameters are invalid</exception> - /// <exception cref="InvalidOperationException">Thrown when tables/columns not found or creation fails</exception> - [ServiceAction("create-relationship")] - OperationResult CreateRelationship( - IExcelBatch batch, - [RequiredParameter] string fromTable, - [RequiredParameter] string fromColumn, - [RequiredParameter] string toTable, - [RequiredParameter] string toColumn, - bool active = true); - - /// <summary> - /// Updates an existing relationship's active state in the Data Model. - /// Uses Excel COM API: ModelRelationship.Active property (Read/Write) - /// </summary> - /// <param name="batch">Excel batch context for accessing workbook</param> - /// <param name="fromTable">Source table name</param> - /// <param name="fromColumn">Source column name</param> - /// <param name="toTable">Target table name</param> - /// <param name="toColumn">Target column name</param> - /// <param name="active">New active state for the relationship</param> - /// <exception cref="ArgumentException">Thrown when parameters are invalid</exception> - /// <exception cref="InvalidOperationException">Thrown when relationship not found or update fails</exception> - [ServiceAction("update-relationship")] - OperationResult UpdateRelationship( - IExcelBatch batch, - [RequiredParameter] string fromTable, - [RequiredParameter] string fromColumn, - [RequiredParameter] string toTable, - [RequiredParameter] string toColumn, - [RequiredParameter] bool active); - - /// <summary> - /// Deletes a relationship from the Data Model - /// </summary> - /// <param name="batch">Excel batch context for accessing workbook</param> - /// <param name="fromTable">Source table name</param> - /// <param name="fromColumn">Source column name</param> - /// <param name="toTable">Target table name</param> - /// <param name="toColumn">Target column name</param> - /// <exception cref="ArgumentException">Thrown when parameters are invalid</exception> - /// <exception cref="InvalidOperationException">Thrown when relationship not found or deletion fails</exception> - [ServiceAction("delete-relationship")] - OperationResult DeleteRelationship( - IExcelBatch batch, - [RequiredParameter] string fromTable, - [RequiredParameter] string fromColumn, - [RequiredParameter] string toTable, - [RequiredParameter] string toColumn); -} diff --git a/src/ExcelMcp.Core/Commands/Diag/DiagCommands.cs b/src/ExcelMcp.Core/Commands/Diag/DiagCommands.cs deleted file mode 100644 index d029872b..00000000 --- a/src/ExcelMcp.Core/Commands/Diag/DiagCommands.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.Diag; - -/// <summary> -/// Implementation of diagnostic commands. -/// These commands don't touch Excel COM — they validate CLI/MCP infrastructure. -/// </summary> -public sealed class DiagCommands : IDiagCommands -{ - /// <inheritdoc /> - public DiagResult Ping() - { - return new DiagResult - { - Success = true, - Action = "ping", - Message = "pong", - Timestamp = DateTime.UtcNow.ToString("o") - }; - } - - /// <inheritdoc /> - public DiagResult Echo(string message, string? tag = null) - { - return new DiagResult - { - Success = true, - Action = "echo", - Message = message, - Tag = tag, - Timestamp = DateTime.UtcNow.ToString("o") - }; - } - - /// <inheritdoc /> - public DiagResult ValidateParams(string name, int count, string? label = null, bool verbose = false) - { - return new DiagResult - { - Success = true, - Action = "validate-params", - Timestamp = DateTime.UtcNow.ToString("o"), - Parameters = new Dictionary<string, object?> - { - ["name"] = name, - ["count"] = count, - ["label"] = label, - ["verbose"] = verbose - } - }; - } -} diff --git a/src/ExcelMcp.Core/Commands/Diag/IDiagCommands.cs b/src/ExcelMcp.Core/Commands/Diag/IDiagCommands.cs deleted file mode 100644 index 5429a245..00000000 --- a/src/ExcelMcp.Core/Commands/Diag/IDiagCommands.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Sbroenne.ExcelMcp.Core.Attributes; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.Diag; - -/// <summary> -/// Diagnostic commands for testing CLI/MCP infrastructure without Excel. -/// These commands validate parameter parsing, routing, JSON serialization, -/// and error handling — no Excel COM session needed. -/// </summary> -[ServiceCategory("diag", "Diag")] -[NoSession] -public interface IDiagCommands -{ - /// <summary> - /// Returns a simple success response. Used to verify the service is running - /// and the CLI/MCP pipeline works end-to-end. - /// </summary> - /// <returns>Success result with timestamp</returns> - DiagResult Ping(); - - /// <summary> - /// Echoes back the provided message. Used to verify parameter parsing - /// and JSON serialization of required string parameters. - /// </summary> - /// <param name="message">The message to echo back (required)</param> - /// <param name="tag">Optional tag to include in the response</param> - /// <returns>Result containing the echoed message and tag</returns> - DiagResult Echo(string message, string? tag = null); - - /// <summary> - /// Validates various parameter types. Used to verify that the CLI/MCP - /// infrastructure correctly parses and validates different parameter - /// combinations (required strings, optional strings, booleans, integers). - /// </summary> - /// <param name="name">Required name parameter</param> - /// <param name="count">Required integer parameter</param> - /// <param name="label">Optional label parameter</param> - /// <param name="verbose">Optional boolean flag (default: false)</param> - /// <returns>Result containing all parsed parameter values</returns> - [ServiceAction("validate-params")] - DiagResult ValidateParams(string name, int count, string? label = null, bool verbose = false); -} diff --git a/src/ExcelMcp.Core/Commands/FileCommands.cs b/src/ExcelMcp.Core/Commands/FileCommands.cs deleted file mode 100644 index c1a9461b..00000000 --- a/src/ExcelMcp.Core/Commands/FileCommands.cs +++ /dev/null @@ -1,51 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// File management commands implementation -/// </summary> -public class FileCommands : IFileCommands -{ - /// <inheritdoc /> - public FileValidationInfo Test(string filePath) - { - filePath = Path.GetFullPath(filePath); - - bool exists = File.Exists(filePath); - string extension = Path.GetExtension(filePath).ToLowerInvariant(); - bool isValidExtension = extension is ".xlsx" or ".xlsm"; - - long size = 0; - DateTime lastModified = DateTime.MinValue; - - if (exists) - { - var fileInfo = new FileInfo(filePath); - size = fileInfo.Length; - lastModified = fileInfo.LastWriteTime; - } - - string? message = !exists - ? $"File not found: {filePath}" - : !isValidExtension ? $"Invalid file extension. Expected .xlsx or .xlsm, got {extension}" : null; - - return new FileValidationInfo - { - FilePath = filePath, - Exists = exists, - Size = size, - Extension = extension, - LastModified = lastModified, - IsValid = exists && isValidExtension, - IsIrmProtected = exists && FileAccessValidator.IsIrmProtected(filePath), - Message = message - }; - } - -} - - - - diff --git a/src/ExcelMcp.Core/Commands/FormattingHelpers.cs b/src/ExcelMcp.Core/Commands/FormattingHelpers.cs deleted file mode 100644 index 85c56c86..00000000 --- a/src/ExcelMcp.Core/Commands/FormattingHelpers.cs +++ /dev/null @@ -1,56 +0,0 @@ -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Shared formatting helper methods for Excel formatting operations. -/// Used by Range and ConditionalFormat commands. -/// </summary> -internal static class FormattingHelpers -{ - /// <summary> - /// Parses a color string to Excel RGB format. - /// Supports #RRGGBB format or color index. - /// </summary> - /// <param name="color">Color in #RRGGBB format or numeric index</param> - /// <returns>Excel RGB integer value</returns> - /// <exception cref="ArgumentException">If color format is invalid</exception> - public static int ParseColor(string color) - { - // Support #RRGGBB format or color index - if (color.StartsWith('#') && color.Length == 7) - { - var r = Convert.ToInt32(color.Substring(1, 2), 16); - var g = Convert.ToInt32(color.Substring(3, 2), 16); - var b = Convert.ToInt32(color.Substring(5, 2), 16); - return r + (g << 8) + (b << 16); // Excel RGB format - } - else if (int.TryParse(color, out var index)) - { - return index; - } - throw new ArgumentException($"Invalid color format: {color}. Use #RRGGBB or color index."); - } - - /// <summary> - /// Parses a border style string to Excel constant. - /// </summary> - /// <param name="style">Border style name</param> - /// <returns>Excel border style constant</returns> - /// <exception cref="ArgumentException">If style is invalid</exception> - public static int ParseBorderStyle(string style) - { - return style.ToLowerInvariant() switch - { - "none" => -4142, // xlNone - "continuous" => 1, // xlContinuous - "dash" => -4115, // xlDash - "dashdot" => 4, // xlDashDot - "dashdotdot" => 5, // xlDashDotDot - "dot" => -4118, // xlDot - "double" => -4119, // xlDouble - "slantdashdot" => 13, // xlSlantDashDot - _ => throw new ArgumentException($"Invalid border style: {style}") - }; - } -} - - diff --git a/src/ExcelMcp.Core/Commands/IFileCommands.cs b/src/ExcelMcp.Core/Commands/IFileCommands.cs deleted file mode 100644 index 8cf4956f..00000000 --- a/src/ExcelMcp.Core/Commands/IFileCommands.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// File management commands for Excel workbooks -/// </summary> -public interface IFileCommands -{ - /// <summary> - /// Tests if a file exists and is a valid Excel file - /// </summary> - /// <param name="filePath">Path to the Excel file to validate</param> - /// <returns>File validation details including existence, size, extension, and validity information</returns> - FileValidationInfo Test(string filePath); -} - - - diff --git a/src/ExcelMcp.Core/Commands/NamedRange/INamedRangeCommands.cs b/src/ExcelMcp.Core/Commands/NamedRange/INamedRangeCommands.cs deleted file mode 100644 index 25d091dd..00000000 --- a/src/ExcelMcp.Core/Commands/NamedRange/INamedRangeCommands.cs +++ /dev/null @@ -1,92 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Attributes; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Named ranges for formulas/parameters. -/// CREATE/UPDATE: value is cell reference (e.g., 'Sheet1!$A$1'). -/// WRITE: value is data to store. -/// TIP: range(rangeAddress=namedRangeName) for bulk data read/write. -/// </summary> -[ServiceCategory("namedrange", "NamedRange")] -[McpTool("namedrange", Title = "Named Range Operations", Destructive = true, Category = "data", - Description = "Named ranges for formulas/parameters. CREATE/UPDATE: value is cell reference (e.g., Sheet1!$A$1). WRITE: value is data to store in the named range. TIP: Use range(rangeAddress=namedRangeName) for bulk data operations.")] -public interface INamedRangeCommands -{ - /// <summary> - /// Lists all named ranges in the workbook - /// </summary> - /// <returns>List of named range information</returns> - /// <exception cref="InvalidOperationException">If workbook access fails</exception> - [ServiceAction("list")] - List<NamedRangeInfo> List(IExcelBatch batch); - - /// <summary> - /// Sets the value of a named range - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="name">Name of the named range</param> - /// <param name="value">Value to set</param> - /// <exception cref="InvalidOperationException">If named range not found</exception> - [ServiceAction("write")] - OperationResult Write( - IExcelBatch batch, - [RequiredParameter, FromString("name")] string name, - [RequiredParameter, FromString("value")] string value); - - /// <summary> - /// Gets the value of a named range - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="name">Name of the named range</param> - /// <returns>Named range value information</returns> - /// <exception cref="InvalidOperationException">If named range not found</exception> - [ServiceAction("read")] - NamedRangeValue Read( - IExcelBatch batch, - [RequiredParameter, FromString("name")] string name); - - /// <summary> - /// Updates a named range reference - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="name">Name of the named range</param> - /// <param name="reference">New cell reference (e.g., Sheet1!$A$1:$B$10)</param> - /// <exception cref="ArgumentException">If name invalid or too long</exception> - /// <exception cref="InvalidOperationException">If named range not found</exception> - [ServiceAction("update")] - OperationResult Update( - IExcelBatch batch, - [RequiredParameter, FromString("name")] string name, - [RequiredParameter, FromString("reference")] string reference); - - /// <summary> - /// Creates a new named range - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="name">Name for the new named range</param> - /// <param name="reference">Cell reference (e.g., Sheet1!$A$1:$B$10)</param> - /// <exception cref="ArgumentException">If name invalid or too long</exception> - /// <exception cref="InvalidOperationException">If named range already exists</exception> - [ServiceAction("create")] - OperationResult Create( - IExcelBatch batch, - [RequiredParameter, FromString("name")] string name, - [RequiredParameter, FromString("reference")] string reference); - - /// <summary> - /// Deletes a named range - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="name">Name of the named range to delete</param> - /// <exception cref="InvalidOperationException">If named range not found</exception> - [ServiceAction("delete")] - OperationResult Delete( - IExcelBatch batch, - [RequiredParameter, FromString("name")] string name); -} - - - diff --git a/src/ExcelMcp.Core/Commands/NamedRange/NamedRangeCommands.Operations.cs b/src/ExcelMcp.Core/Commands/NamedRange/NamedRangeCommands.Operations.cs deleted file mode 100644 index ab826614..00000000 --- a/src/ExcelMcp.Core/Commands/NamedRange/NamedRangeCommands.Operations.cs +++ /dev/null @@ -1,306 +0,0 @@ -using System.Runtime.InteropServices; -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; -using Excel = Microsoft.Office.Interop.Excel; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Named range lifecycle operations (List, Read, Write, Create, Update, Delete) -/// </summary> -public partial class NamedRangeCommands -{ - /// <inheritdoc /> - public List<NamedRangeInfo> List(IExcelBatch batch) - { - return batch.Execute((ctx, ct) => - { - var namedRanges = new List<NamedRangeInfo>(); - dynamic? namesCollection = null; - try - { - namesCollection = ctx.Book.Names; - int count = namesCollection.Count; - - for (int i = 1; i <= count; i++) - { - dynamic? nameObj = null; - dynamic? refersToRange = null; - try - { - nameObj = namesCollection.Item(i); - string name = nameObj.Name; - string refersTo = nameObj.RefersTo ?? ""; - - // Try to get value - object? value = null; - string valueType = "null"; - try - { - refersToRange = nameObj.RefersToRange; - var rawValue = refersToRange?.Value2; - - // Convert 2D array to List<List<object?>> for JSON serialization - if (rawValue is object[,] array2D) - { - value = ConvertArrayToList(array2D); - valueType = "Array"; - } - else - { - value = rawValue; - valueType = rawValue?.GetType().Name ?? "null"; - } - } - catch (COMException) - { - // Named range may not have a valid RefersToRange (e.g., formula-based or external reference) - // Continue with null value - this is expected for some named ranges - } - - namedRanges.Add(new NamedRangeInfo - { - Name = name, - RefersTo = refersTo, - Value = value, - ValueType = valueType - }); - } - catch (COMException) - { - // Skip corrupted or inaccessible named ranges - continue listing remaining - continue; - } - finally - { - ComUtilities.Release(ref refersToRange); - ComUtilities.Release(ref nameObj); - } - } - - return namedRanges; - } - finally - { - ComUtilities.Release(ref namesCollection); - } - }); - } - - /// <inheritdoc /> - public OperationResult Write(IExcelBatch batch, string name, string value) - { - return batch.Execute((ctx, ct) => - { - Excel.Name? nameObj = null; - dynamic? refersToRange = null; - int originalCalculation = -1;// xlCalculationAutomatic = -4105, xlCalculationManual = -4135 - bool calculationChanged = false; - - try - { - nameObj = ComUtilities.FindName(ctx.Book, name); - if (nameObj == null) - { - throw new InvalidOperationException($"Named range '{name}' not found."); - } - - refersToRange = nameObj.RefersToRange; - - // CRITICAL: Temporarily disable automatic calculation to prevent Excel from - // hanging when changed parameter values trigger dependent formulas that reference Data Model/DAX. - // Without this, setting values can block the COM interface during recalculation. - originalCalculation = (int)ctx.App.Calculation; - if (originalCalculation != -4135) // xlCalculationManual - { - ctx.App.Calculation = (Excel.XlCalculation)(-4135); // xlCalculationManual - calculationChanged = true; - } - - // Try to parse as number, otherwise set as text - if (double.TryParse(value, out double numValue)) - { - refersToRange.Value2 = numValue; - } - else if (bool.TryParse(value, out bool boolValue)) - { - refersToRange.Value2 = boolValue; - } - else - { - refersToRange.Value2 = value; - } - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; // Dummy return for batch.Execute - } - finally - { - // Restore original calculation mode - if (calculationChanged && originalCalculation != -1) - { - try - { - ctx.App.Calculation = (Excel.XlCalculation)originalCalculation; - } - catch (System.Runtime.InteropServices.COMException) - { - // Ignore errors restoring calculation mode - not critical - } - } - ComUtilities.Release(ref refersToRange); - ComUtilities.Release(ref nameObj); - } - }); - } - - /// <inheritdoc /> - public NamedRangeValue Read(IExcelBatch batch, string name) - { - return batch.Execute((ctx, ct) => - { - Excel.Name? nameObj = null; - dynamic? refersToRange = null; - try - { - nameObj = ComUtilities.FindName(ctx.Book, name); - if (nameObj == null) - { - throw new InvalidOperationException($"Named range '{name}' not found."); - } - - string refersTo = nameObj.RefersTo?.ToString() ?? ""; - refersToRange = nameObj.RefersToRange; - object? value = refersToRange?.Value2; - string valueType = value?.GetType().Name ?? "null"; - - return new NamedRangeValue - { - Name = name, - RefersTo = refersTo, - Value = value, - ValueType = valueType - }; - } - finally - { - ComUtilities.Release(ref refersToRange); - ComUtilities.Release(ref nameObj); - } - }); - } - - /// <inheritdoc /> - public OperationResult Create(IExcelBatch batch, string name, string reference) - { - // Validate parameter name length (Excel limit: 255 characters) - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentException("Named range name cannot be empty or whitespace", nameof(name)); - } - - if (name.Length > 255) - { - throw new ArgumentException($"Named range name exceeds Excel's 255-character limit (current length: {name.Length})", nameof(name)); - } - - return batch.Execute((ctx, ct) => - { - Excel.Name? existing = null; - dynamic? namesCollection = null; - try - { - // Check if parameter already exists - existing = ComUtilities.FindName(ctx.Book, name); - if (existing != null) - { - throw new InvalidOperationException($"Named range '{name}' already exists"); - } - - // Create new named range - namesCollection = ctx.Book.Names; - // Remove any existing = prefix to avoid double == - string formattedReference = reference.TrimStart('='); - // Add exactly one = prefix (required by Excel COM API) - formattedReference = $"={formattedReference}"; - namesCollection.Add(name, formattedReference); - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; // Dummy return for batch.Execute - } - finally - { - ComUtilities.Release(ref namesCollection); - ComUtilities.Release(ref existing); - } - }); - } - - /// <inheritdoc /> - public OperationResult Update(IExcelBatch batch, string name, string reference) - { - // Validate parameter name length (Excel limit: 255 characters) - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentException("Named range name cannot be empty or whitespace", nameof(name)); - } - - if (name.Length > 255) - { - throw new ArgumentException($"Named range name exceeds Excel's 255-character limit (current length: {name.Length})", nameof(name)); - } - - return batch.Execute((ctx, ct) => - { - Excel.Name? nameObj = null; - try - { - nameObj = ComUtilities.FindName(ctx.Book, name); - if (nameObj == null) - { - throw new InvalidOperationException($"Named range '{name}' not found."); - } - - // Remove any existing = prefix to avoid double == - string formattedReference = reference.TrimStart('='); - // Add exactly one = prefix (required by Excel COM API) - formattedReference = $"={formattedReference}"; - - // Update the reference - nameObj.RefersTo = formattedReference; - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; // Dummy return for batch.Execute - } - finally - { - ComUtilities.Release(ref nameObj); - } - }); - } - - /// <inheritdoc /> - public OperationResult Delete(IExcelBatch batch, string name) - { - return batch.Execute((ctx, ct) => - { - Excel.Name? nameObj = null; - try - { - nameObj = ComUtilities.FindName(ctx.Book, name); - if (nameObj == null) - { - throw new InvalidOperationException($"Named range '{name}' not found."); - } - - nameObj.Delete(); - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; // Dummy return for batch.Execute - } - finally - { - ComUtilities.Release(ref nameObj); - } - }); - } -} - - - diff --git a/src/ExcelMcp.Core/Commands/NamedRange/NamedRangeCommands.cs b/src/ExcelMcp.Core/Commands/NamedRange/NamedRangeCommands.cs deleted file mode 100644 index 75f667dc..00000000 --- a/src/ExcelMcp.Core/Commands/NamedRange/NamedRangeCommands.cs +++ /dev/null @@ -1,31 +0,0 @@ -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Named range/parameter management commands implementation -/// </summary> -public partial class NamedRangeCommands : INamedRangeCommands -{ - private static List<List<object?>> ConvertArrayToList(object[,] array2D) - { - var result = new List<List<object?>>(); - - // Excel arrays are 1-based, get the bounds - int rows = array2D.GetLength(0); - int cols = array2D.GetLength(1); - - for (int row = 1; row <= rows; row++) - { - var rowList = new List<object?>(); - for (int col = 1; col <= cols; col++) - { - rowList.Add(array2D[row, col]); - } - result.Add(rowList); - } - - return result; - } -} - - - diff --git a/src/ExcelMcp.Core/Commands/PivotTable/IPivotTableCalcCommands.cs b/src/ExcelMcp.Core/Commands/PivotTable/IPivotTableCalcCommands.cs deleted file mode 100644 index b87716ab..00000000 --- a/src/ExcelMcp.Core/Commands/PivotTable/IPivotTableCalcCommands.cs +++ /dev/null @@ -1,210 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Attributes; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.PivotTable; - -/// <summary> -/// PivotTable calculated fields/members, layout configuration, and data extraction. -/// Use pivottable for lifecycle, pivottablefield for field placement. -/// -/// CALCULATED FIELDS (for regular PivotTables): -/// - Create custom fields using formulas like '=Revenue-Cost' or '=Quantity*UnitPrice' -/// - Can reference existing fields by name -/// - After creating, use pivottablefield add-value-field to add to Values area -/// - For complex multi-table calculations, prefer DAX measures with datamodel -/// -/// CALCULATED MEMBERS (for OLAP/Data Model PivotTables only): -/// - Create using MDX expressions -/// - Member types: Member, Set, Measure -/// -/// LAYOUT OPTIONS: -/// - 0 = Compact (default, fields in single column) -/// - 1 = Tabular (each field in separate column - best for export/analysis) -/// - 2 = Outline (hierarchical with expand/collapse) -/// </summary> -[ServiceCategory("pivottablecalc", "PivotTableCalc")] -[McpTool("pivottable_calc", Title = "PivotTable Calc Operations", Destructive = true, Category = "analysis", - Description = "PivotTable calculated fields/members, layout configuration, and data extraction. CALCULATED FIELDS: Create formulas like =Revenue-Cost, then add to Values with pivottable_field. CALCULATED MEMBERS: MDX expressions (OLAP/Data Model only). LAYOUT: 0=Compact, 1=Tabular, 2=Outline. Use pivottable for lifecycle, pivottable_field for field management.")] -public interface IPivotTableCalcCommands -{ - // === ANALYSIS OPERATIONS (WITH DATA VALIDATION) === - - /// <summary> - /// Gets current PivotTable data as 2D array for LLM analysis - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Name of the PivotTable</param> - /// <returns>Values with headers, row/column labels, formatted numbers</returns> - [ServiceAction("get-data")] - PivotTableDataResult GetData(IExcelBatch batch, string pivotTableName); - - /// <summary> - /// Creates a calculated field with a custom formula. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Name of the PivotTable</param> - /// <param name="fieldName">Name for the calculated field</param> - /// <param name="formula">Formula using field references (e.g., "=Revenue-Cost")</param> - /// <returns>Result with calculated field details</returns> - /// <remarks> - /// Formula examples: - /// - "=Revenue-Cost" creates Profit field - /// - "=Profit/Revenue" creates Margin field - /// - "=(Actual-Budget)/Budget" creates Variance% field - /// - /// NOTE: OLAP PivotTables do not support CalculatedFields. - /// For OLAP, use Data Model DAX measures instead. - /// </remarks> - [ServiceAction("create-calculated-field")] - PivotFieldResult CreateCalculatedField(IExcelBatch batch, string pivotTableName, - string fieldName, string formula); - - /// <summary> - /// Lists all calculated fields in a regular PivotTable. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Name of the PivotTable</param> - /// <returns>List of calculated fields with names and formulas</returns> - /// <remarks> - /// NOTE: OLAP PivotTables do not support CalculatedFields. - /// Use ListCalculatedMembers for OLAP PivotTables instead. - /// </remarks> - [ServiceAction("list-calculated-fields")] - CalculatedFieldListResult ListCalculatedFields(IExcelBatch batch, string pivotTableName); - - /// <summary> - /// Deletes a calculated field from a regular PivotTable. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Name of the PivotTable</param> - /// <param name="fieldName">Name of the calculated field to delete</param> - /// <returns>Result indicating success or failure</returns> - /// <remarks> - /// NOTE: OLAP PivotTables do not support CalculatedFields. - /// Use DeleteCalculatedMember for OLAP PivotTables instead. - /// </remarks> - [ServiceAction("delete-calculated-field")] - OperationResult DeleteCalculatedField(IExcelBatch batch, string pivotTableName, string fieldName); - - // === CALCULATED MEMBERS (OLAP ONLY) === - - /// <summary> - /// Lists all calculated members in an OLAP PivotTable. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Name of the PivotTable</param> - /// <returns>List of calculated members with names, formulas, types</returns> - /// <remarks> - /// OLAP ONLY: Calculated members are only available for OLAP PivotTables (Data Model-based). - /// Regular PivotTables use calculated fields instead (see CreateCalculatedField). - /// - /// CALCULATED MEMBER TYPES: - /// - Member: Custom MDX formula creating a new member in a hierarchy - /// - Set: Named set of members for filtering/grouping - /// - Measure: DAX-like calculated measure for Data Model - /// </remarks> - [ServiceAction("list-calculated-members")] - CalculatedMemberListResult ListCalculatedMembers(IExcelBatch batch, string pivotTableName); - - /// <summary> - /// Creates a calculated member (MDX formula) in an OLAP PivotTable. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Name of the PivotTable</param> - /// <param name="memberName">Name for the calculated member (MDX naming format)</param> - /// <param name="formula">MDX formula for the calculated member</param> - /// <param name="type">Type of calculated member (Member, Set, or Measure)</param> - /// <param name="solveOrder">Solve order for calculation precedence (default: 0)</param> - /// <param name="displayFolder">Display folder path for organizing measures (optional)</param> - /// <param name="numberFormat">Number format code for the calculated member (optional)</param> - /// <returns>Result with created calculated member details</returns> - /// <remarks> - /// OLAP ONLY: Works only with OLAP PivotTables (Data Model-based). - /// Regular PivotTables should use CreateCalculatedField instead. - /// - /// MDX FORMULA EXAMPLES: - /// - Measure: "[Measures].[Profit]" formula = "[Measures].[Revenue] - [Measures].[Cost]" - /// - Member: "[Product].[Category].[All].[High Margin]" formula = "Aggregate({[Product].[Category].&[A], [Product].[Category].&[B]})" - /// - /// SOLVE ORDER: - /// - Higher solve order = calculated later (can reference lower solve order members) - /// - Default is 0, use higher values for dependent calculations - /// </remarks> - [ServiceAction("create-calculated-member")] - CalculatedMemberResult CreateCalculatedMember(IExcelBatch batch, string pivotTableName, - string memberName, string formula, [FromString] CalculatedMemberType type = CalculatedMemberType.Measure, - int solveOrder = 0, string? displayFolder = null, string? numberFormat = null); - - /// <summary> - /// Deletes a calculated member from an OLAP PivotTable. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Name of the PivotTable</param> - /// <param name="memberName">Name of the calculated member to delete</param> - /// <returns>Operation result indicating success or failure</returns> - /// <remarks> - /// OLAP ONLY: Works only with OLAP PivotTables (Data Model-based). - /// </remarks> - [ServiceAction("delete-calculated-member")] - OperationResult DeleteCalculatedMember(IExcelBatch batch, string pivotTableName, string memberName); - - /// <summary> - /// Sets the row layout form for a PivotTable. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Name of the PivotTable</param> - /// <param name="rowLayout">Layout form: 0=Compact, 1=Tabular, 2=Outline</param> - /// <returns>Result indicating success or failure</returns> - /// <remarks> - /// LAYOUT FORMS: - /// - Compact (0): All row fields in single column with indentation (Excel default) - /// - Tabular (1): Each field in separate column, subtotals at bottom - /// - Outline (2): Each field in separate column, subtotals at top - /// - /// Supported by both regular and OLAP PivotTables. - /// </remarks> - [ServiceAction("set-layout")] - OperationResult SetLayout(IExcelBatch batch, string pivotTableName, int rowLayout); - - /// <summary> - /// Shows or hides subtotals for a specific row field. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Name of the PivotTable</param> - /// <param name="fieldName">Name of the row field</param> - /// <param name="showSubtotals">True to show automatic subtotals, false to hide</param> - /// <returns>Result with updated field configuration</returns> - /// <remarks> - /// SUBTOTALS: - /// - Enabled: Shows automatic subtotals (Sum for numbers, Count for text) - /// - Disabled: Hides all subtotals, shows only detail rows - /// - /// OLAP PivotTables only support Automatic subtotals. - /// </remarks> - [ServiceAction("set-subtotals")] - PivotFieldResult SetSubtotals(IExcelBatch batch, string pivotTableName, - string fieldName, bool showSubtotals); - - /// <summary> - /// Shows or hides grand totals for rows and/or columns in the PivotTable. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Name of the PivotTable to configure</param> - /// <param name="showRowGrandTotals">Show row grand totals (bottom summary row)</param> - /// <param name="showColumnGrandTotals">Show column grand totals (right summary column)</param> - /// <returns>Operation result indicating success or failure</returns> - /// <remarks> - /// GRAND TOTALS: - /// - Row Grand Totals: Summary row at bottom of PivotTable - /// - Column Grand Totals: Summary column at right of PivotTable - /// - Independent control: Can show/hide row and column separately - /// - /// SUPPORT: - /// - Regular PivotTables: Full support - /// - OLAP PivotTables: Full support - /// </remarks> - [ServiceAction("set-grand-totals")] - OperationResult SetGrandTotals(IExcelBatch batch, string pivotTableName, - bool showRowGrandTotals, bool showColumnGrandTotals); -} diff --git a/src/ExcelMcp.Core/Commands/PivotTable/IPivotTableCommands.cs b/src/ExcelMcp.Core/Commands/PivotTable/IPivotTableCommands.cs deleted file mode 100644 index 195f8d15..00000000 --- a/src/ExcelMcp.Core/Commands/PivotTable/IPivotTableCommands.cs +++ /dev/null @@ -1,113 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Attributes; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.PivotTable; - -/// <summary> -/// PivotTable lifecycle management: create from various sources, list, read details, refresh, and delete. -/// Use pivottablefield for field operations, pivottablecalc for calculated fields and layout. -/// -/// BEST PRACTICE: Use 'list' before creating. Prefer 'refresh' or field modifications over delete+recreate. -/// Delete+recreate loses field configurations, filters, sorting, and custom layouts. -/// -/// REFRESH: Call 'refresh' after configuring fields with pivottablefield to update the visual display. -/// This is especially important for OLAP/Data Model PivotTables where field operations -/// are structural only and don't automatically trigger a visual refresh. -/// -/// CREATE OPTIONS: -/// - 'create-from-range': Use source sheet and range address for data range -/// - 'create-from-table': Use an Excel Table (ListObject) as source -/// - 'create-from-datamodel': Use a Power Pivot Data Model table as source -/// </summary> -[ServiceCategory("pivottable", "PivotTable")] -[McpTool("pivottable", Title = "PivotTable Operations", Destructive = true, Category = "analysis", - Description = "PivotTable lifecycle: create from various sources, list, read, refresh, delete. BEST PRACTICE: Use list before creating. Prefer refresh over delete+recreate to preserve field configs. REFRESH: Call after configuring fields with pivottable_field. LAYOUT: 0=Compact (default), 1=Tabular (best for export), 2=Outline. CREATE: create-from-range, create-from-table, create-from-datamodel. TIMEOUT: 5 min for DataModel. STYLING: PivotTable visual styles are not supported by this API. Do not apply range_format to PivotTable cells — cell formatting is overwritten on the next refresh. Use pivottable_field for field management, pivottable_calc for calculated fields.")] -public interface IPivotTableCommands -{ - // === LIFECYCLE OPERATIONS === - - /// <summary> - /// Lists all PivotTables in workbook with structure details - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <returns>List of PivotTables with names, sheets, ranges, source data, field counts, last refresh</returns> - [ServiceAction("list")] - PivotTableListResult List(IExcelBatch batch); - - /// <summary> - /// Gets complete PivotTable configuration and current layout - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Name of the PivotTable</param> - /// <returns>All fields with positions, aggregation functions, filter states</returns> - [ServiceAction("read")] - PivotTableInfoResult Read(IExcelBatch batch, string pivotTableName); - - /// <summary> - /// Creates PivotTable from Excel range with auto-detection of headers - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sourceSheet">Source worksheet name</param> - /// <param name="sourceRange">Source range address (e.g., "A1:F100")</param> - /// <param name="destinationSheet">Destination worksheet name</param> - /// <param name="destinationCell">Destination cell address (e.g., "A1")</param> - /// <param name="pivotTableName">Name for the new PivotTable</param> - /// <returns>Created PivotTable name and initial field list</returns> - [ServiceAction("create-from-range")] - PivotTableCreateResult CreateFromRange(IExcelBatch batch, - string sourceSheet, string sourceRange, - string destinationSheet, string destinationCell, - string pivotTableName); - - /// <summary> - /// Creates PivotTable from Excel Table (ListObject) - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="tableName">Name of the Excel Table</param> - /// <param name="destinationSheet">Destination worksheet name</param> - /// <param name="destinationCell">Destination cell address (e.g., "A1")</param> - /// <param name="pivotTableName">Name for the new PivotTable</param> - /// <returns>Created PivotTable name and available fields</returns> - [ServiceAction("create-from-table")] - PivotTableCreateResult CreateFromTable(IExcelBatch batch, - string tableName, - string destinationSheet, string destinationCell, - string pivotTableName); - - /// <summary> - /// Creates PivotTable from Power Pivot Data Model table - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="tableName">Name of the Data Model table</param> - /// <param name="destinationSheet">Destination worksheet name</param> - /// <param name="destinationCell">Destination cell address (e.g., "A1")</param> - /// <param name="pivotTableName">Name for the new PivotTable</param> - /// <returns>Created PivotTable name and available fields</returns> - [ServiceAction("create-from-datamodel")] - PivotTableCreateResult CreateFromDataModel(IExcelBatch batch, - string tableName, - string destinationSheet, string destinationCell, - string pivotTableName); - - /// <summary> - /// Deletes PivotTable completely - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Name of the PivotTable to delete</param> - /// <returns>Operation result</returns> - [ServiceAction("delete")] - OperationResult Delete(IExcelBatch batch, string pivotTableName); - - /// <summary> - /// Refreshes PivotTable data from source and returns updated info - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Name of the PivotTable to refresh</param> - /// <param name="timeout">Optional timeout for the refresh operation</param> - /// <returns>Refresh timestamp, record count, any structural changes</returns> - [ServiceAction("refresh")] - PivotTableRefreshResult Refresh(IExcelBatch batch, string pivotTableName, TimeSpan? timeout = null); -} - - diff --git a/src/ExcelMcp.Core/Commands/PivotTable/IPivotTableFieldCommands.cs b/src/ExcelMcp.Core/Commands/PivotTable/IPivotTableFieldCommands.cs deleted file mode 100644 index 7324cb4f..00000000 --- a/src/ExcelMcp.Core/Commands/PivotTable/IPivotTableFieldCommands.cs +++ /dev/null @@ -1,216 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Attributes; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.PivotTable; - -/// <summary> -/// PivotTable field management: add/remove/configure fields, filtering, sorting, and grouping. -/// Use pivottable for lifecycle, pivottablecalc for calculated fields and layout. -/// -/// IMPORTANT: Field operations modify structure only. Call pivottable refresh after -/// configuring fields to update the visual display, especially for OLAP/Data Model PivotTables. -/// -/// FIELD AREAS: -/// - Row fields: Group data by categories (add-row-field) -/// - Column fields: Create column headers (add-column-field) -/// - Value fields: Aggregate numeric data with Sum, Count, Average, etc. (add-value-field) -/// - Filter fields: Add report-level filters (add-filter-field) -/// -/// AGGREGATION FUNCTIONS: Sum, Count, Average, Max, Min, Product, CountNumbers, StdDev, StdDevP, Var, VarP -/// -/// GROUPING: -/// - Date fields: Group by Days, Months, Quarters, Years (group-by-date) -/// - Numeric fields: Group by ranges with start/end/interval (group-by-numeric) -/// -/// NUMBER FORMAT: Use US format codes like '#,##0.00' for currency or '0.00%' for percentages. -/// </summary> -[ServiceCategory("pivottablefield", "PivotTableField")] -[McpTool("pivottable_field", Title = "PivotTable Field Operations", Destructive = true, Category = "analysis", - Description = "PivotTable field management: add/remove/configure fields, filtering, sorting, and grouping. IMPORTANT: Field operations modify structure only - call pivottable(refresh) after configuring, especially for OLAP/Data Model PivotTables. FIELD AREAS: Row (categories), Column (headers), Value (aggregation: Sum/Count/Average/Max/Min/etc.), Filter (report-level). GROUPING: date (Days/Months/Quarters/Years), numeric (start/end/interval). NUMBER FORMAT: US format codes. Use pivottable for lifecycle, pivottable_calc for calculated fields.")] -public interface IPivotTableFieldCommands -{ - // === FIELD MANAGEMENT (WITH IMMEDIATE VALIDATION) === - - /// <summary> - /// Lists all available fields and their current placement - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Name of the PivotTable</param> - /// <returns>Field names, data types, current areas, aggregation functions</returns> - [ServiceAction("list-fields")] - PivotFieldListResult ListFields(IExcelBatch batch, string pivotTableName); - - /// <summary> - /// Adds field to Row area with position validation - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Name of the PivotTable</param> - /// <param name="fieldName">Name of the field to add</param> - /// <param name="position">Optional position in row area (1-based)</param> - /// <returns>Updated field layout with new position</returns> - [ServiceAction("add-row-field")] - PivotFieldResult AddRowField(IExcelBatch batch, string pivotTableName, - string fieldName, int? position = null); - - /// <summary> - /// Adds field to Column area with position validation - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Name of the PivotTable</param> - /// <param name="fieldName">Name of the field to add</param> - /// <param name="position">Optional position in column area (1-based)</param> - /// <returns>Updated field layout</returns> - [ServiceAction("add-column-field")] - PivotFieldResult AddColumnField(IExcelBatch batch, string pivotTableName, - string fieldName, int? position = null); - - /// <summary> - /// Adds field to Values area with aggregation function. - /// - /// For OLAP PivotTables, supports TWO modes: - /// 1. Pre-existing measure: fieldName = "Total Sales" or "[Measures].[Total Sales]" - /// - Adds existing DAX measure without creating duplicate - /// - aggregationFunction ignored (measure formula defines aggregation) - /// 2. Auto-create measure: fieldName = "Sales" (column name) - /// - Creates new DAX measure with specified aggregation function - /// - customName becomes the measure name - /// - /// For Regular PivotTables: Adds column with aggregation function - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Name of the PivotTable</param> - /// <param name="fieldName">Field/column name OR existing measure name (OLAP)</param> - /// <param name="aggregationFunction">Aggregation function (for Regular and OLAP auto-create mode)</param> - /// <param name="customName">Optional custom name for the field/measure</param> - /// <returns>Field configuration with applied function and custom name</returns> - [ServiceAction("add-value-field")] - PivotFieldResult AddValueField(IExcelBatch batch, string pivotTableName, - string fieldName, [FromString] AggregationFunction aggregationFunction = AggregationFunction.Sum, - string? customName = null); - - /// <summary> - /// Adds field to Filter area (Page field) - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Name of the PivotTable</param> - /// <param name="fieldName">Name of the field to add</param> - /// <returns>Field configuration with available filter items</returns> - [ServiceAction("add-filter-field")] - PivotFieldResult AddFilterField(IExcelBatch batch, string pivotTableName, - string fieldName); - - /// <summary> - /// Removes field from any area - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Name of the PivotTable</param> - /// <param name="fieldName">Name of the field to remove</param> - /// <returns>Updated layout after removal</returns> - [ServiceAction("remove-field")] - PivotFieldResult RemoveField(IExcelBatch batch, string pivotTableName, - string fieldName); - - // === FIELD CONFIGURATION (WITH RESULT VERIFICATION) === - - /// <summary> - /// Sets aggregation function for value field - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Name of the PivotTable</param> - /// <param name="fieldName">Name of the field</param> - /// <param name="aggregationFunction">Aggregation function to set</param> - /// <returns>Applied function and sample calculation result</returns> - [ServiceAction("set-field-function")] - PivotFieldResult SetFieldFunction(IExcelBatch batch, string pivotTableName, - string fieldName, [FromString] AggregationFunction aggregationFunction); - - /// <summary> - /// Sets custom name for field in any area - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Name of the PivotTable</param> - /// <param name="fieldName">Name of the field</param> - /// <param name="customName">Custom name to set</param> - /// <returns>Applied name and field reference</returns> - [ServiceAction("set-field-name")] - PivotFieldResult SetFieldName(IExcelBatch batch, string pivotTableName, - string fieldName, string customName); - - /// <summary> - /// Sets number format for value field - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Name of the PivotTable</param> - /// <param name="fieldName">Name of the field</param> - /// <param name="numberFormat">Number format string</param> - /// <returns>Applied format with sample formatted value</returns> - [ServiceAction("set-field-format")] - PivotFieldResult SetFieldFormat(IExcelBatch batch, string pivotTableName, - string fieldName, string numberFormat); - - // === ANALYSIS OPERATIONS (WITH DATA VALIDATION) === - - /// <summary> - /// Sets filter for field with validation of filter items - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Name of the PivotTable</param> - /// <param name="fieldName">Name of the field to filter</param> - /// <param name="selectedValues">Values to show (others will be hidden)</param> - /// <returns>Applied filter state and affected row count</returns> - [ServiceAction("set-field-filter")] - PivotFieldFilterResult SetFieldFilter(IExcelBatch batch, string pivotTableName, - string fieldName, List<string> selectedValues); - - /// <summary> - /// Sorts field with immediate layout update - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Name of the PivotTable</param> - /// <param name="fieldName">Name of the field to sort</param> - /// <param name="direction">Sort direction</param> - /// <returns>Applied sort configuration and preview of changes</returns> - [ServiceAction("sort-field")] - PivotFieldResult SortField(IExcelBatch batch, string pivotTableName, - string fieldName, [FromString] SortDirection direction = SortDirection.Ascending); - - // === GROUPING OPERATIONS (DATE AND NUMERIC) === - - /// <summary> - /// Groups date/time field by specified interval (Month, Quarter, Year) - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Name of the PivotTable</param> - /// <param name="fieldName">Name of the date/time field to group</param> - /// <param name="interval">Grouping interval (Months, Quarters, Years)</param> - /// <returns>Applied grouping configuration and resulting group count</returns> - /// <remarks> - /// Creates automatic date hierarchy in PivotTable (e.g., Years > Quarters > Months). - /// Works for both regular and OLAP PivotTables. - /// Example: Group "OrderDate" by Months to see monthly sales trends. - /// </remarks> - [ServiceAction("group-by-date")] - PivotFieldResult GroupByDate(IExcelBatch batch, string pivotTableName, - string fieldName, [FromString] DateGroupingInterval interval); - - /// <summary> - /// Groups a numeric field by specified interval (e.g., 0-100, 100-200, 200-300). - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Name of PivotTable</param> - /// <param name="fieldName">Field to group</param> - /// <param name="start">Starting value (null = use field minimum)</param> - /// <param name="endValue">Ending value (null = use field maximum)</param> - /// <param name="intervalSize">Size of each group (e.g., 100 for groups of 100)</param> - /// <returns>Grouping result with created groups</returns> - /// <remarks> - /// Creates numeric range groups in PivotTable for analysis. - /// Use cases: Age groups (0-20, 20-40), price ranges (0-100, 100-200), score bands (0-50, 50-100). - /// Works for regular PivotTables. OLAP PivotTables require grouping in Data Model. - /// Example: Group "Sales" by 100 to analyze sales distribution across price ranges. - /// </remarks> - [ServiceAction("group-by-numeric")] - PivotFieldResult GroupByNumeric(IExcelBatch batch, string pivotTableName, - string fieldName, double? start, double? endValue, double intervalSize); -} diff --git a/src/ExcelMcp.Core/Commands/PivotTable/IPivotTableFieldStrategy.cs b/src/ExcelMcp.Core/Commands/PivotTable/IPivotTableFieldStrategy.cs deleted file mode 100644 index 98edec03..00000000 --- a/src/ExcelMcp.Core/Commands/PivotTable/IPivotTableFieldStrategy.cs +++ /dev/null @@ -1,202 +0,0 @@ -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.PivotTable; - -/// <summary> -/// Strategy interface for PivotTable field operations. -/// Handles different PivotTable types (Regular vs OLAP/Data Model). -/// </summary> -public interface IPivotTableFieldStrategy -{ - /// <summary> - /// Determines if this strategy can handle the given PivotTable - /// </summary> - bool CanHandle(dynamic pivot); - - /// <summary> - /// Gets a field for manipulation from the PivotTable. - /// Returns CubeField for OLAP, PivotField for regular. - /// </summary> - dynamic GetFieldForManipulation(dynamic pivot, string fieldName); - - /// <summary> - /// Lists all fields in the PivotTable - /// </summary> - PivotFieldListResult ListFields(dynamic pivot, string workbookPath); - - /// <summary> - /// Adds a field to the Row area - /// </summary> - PivotFieldResult AddRowField(dynamic pivot, string fieldName, int? position, string workbookPath); - - /// <summary> - /// Adds a field to the Column area - /// </summary> - PivotFieldResult AddColumnField(dynamic pivot, string fieldName, int? position, string workbookPath); - - /// <summary> - /// Adds a field to the Values area with aggregation - /// </summary> - PivotFieldResult AddValueField(dynamic pivot, string fieldName, AggregationFunction aggregationFunction, string? customName, string workbookPath); - - /// <summary> - /// Adds a field to the Filter area - /// </summary> - PivotFieldResult AddFilterField(dynamic pivot, string fieldName, string workbookPath); - - /// <summary> - /// Removes a field from any area - /// </summary> - PivotFieldResult RemoveField(dynamic pivot, string fieldName, string workbookPath); - - /// <summary> - /// Sets custom name for a field - /// </summary> - PivotFieldResult SetFieldName(dynamic pivot, string fieldName, string customName, string workbookPath); - - /// <summary> - /// Sets aggregation function for a value field - /// </summary> - PivotFieldResult SetFieldFunction(dynamic pivot, string fieldName, AggregationFunction aggregationFunction, string workbookPath); - - /// <summary> - /// Sets format for a value field - /// </summary> - PivotFieldResult SetFieldFormat(dynamic pivot, string fieldName, string numberFormat, string workbookPath); - - /// <summary> - /// Sets filter for a field - /// </summary> - PivotFieldFilterResult SetFieldFilter(dynamic pivot, string fieldName, List<string> filterValues, string workbookPath); - - /// <summary> - /// Sorts a field - /// </summary> - PivotFieldResult SortField(dynamic pivot, string fieldName, SortDirection direction, string workbookPath); - - /// <summary> - /// Groups a date/time field by specified interval (Days, Months, Quarters, Years). - /// </summary> - /// <remarks> - /// CRITICAL REQUIREMENT: Source data MUST be formatted with date NumberFormat BEFORE creating the PivotTable. - /// Excel stores dates as serial numbers (e.g., 45672 = 2025-01-15). Without proper date formatting, - /// Excel treats these as plain numbers and grouping silently fails. - /// - /// Example: - /// <code> - /// // Format source data BEFORE creating PivotTable - /// sheet.Range["D2:D6"].NumberFormat = "m/d/yyyy"; - /// </code> - /// </remarks> - PivotFieldResult GroupByDate(dynamic pivot, string fieldName, DateGroupingInterval interval, string workbookPath, Microsoft.Extensions.Logging.ILogger? logger = null); - - /// <summary> - /// Groups a numeric field by specified interval (e.g., 0-10, 10-20, 20-30). - /// </summary> - /// <param name="pivot">The PivotTable object</param> - /// <param name="fieldName">Field to group</param> - /// <param name="start">Starting value (null = use field minimum)</param> - /// <param name="endValue">Ending value (null = use field maximum)</param> - /// <param name="intervalSize">Size of each group (e.g., 10 for groups of 10)</param> - /// <param name="workbookPath">Path to workbook for error reporting</param> - /// <param name="logger">Optional logger for diagnostics</param> - /// <returns>Result indicating success or failure</returns> - /// <remarks> - /// Use cases: Age groups (0-20, 20-40), price ranges (0-100, 100-200), score bands (0-50, 50-100). - /// Source data should be formatted with numeric NumberFormat for reliable grouping. - /// If start/end are null, Excel automatically uses the field's minimum/maximum values. - /// </remarks> - PivotFieldResult GroupByNumeric(dynamic pivot, string fieldName, double? start, double? endValue, double intervalSize, string workbookPath, Microsoft.Extensions.Logging.ILogger? logger = null); - - /// <summary> - /// Creates a calculated field with a custom formula. - /// </summary> - /// <param name="pivot">The PivotTable object</param> - /// <param name="fieldName">Name for the calculated field</param> - /// <param name="formula">Formula using field references (e.g., "=Revenue-Cost" or "=Profit/Revenue")</param> - /// <param name="workbookPath">Path to workbook for error reporting</param> - /// <param name="logger">Optional logger for diagnostics</param> - /// <returns>Result indicating success or failure</returns> - /// <remarks> - /// FORMULA SYNTAX: - /// - Use field names in formulas: =Revenue-Cost, =Profit/Revenue*100 - /// - Operators: + - * / ^ () for basic arithmetic - /// - Excel will auto-convert field names to proper references - /// - Example: "Profit" field formula "=Revenue-Cost" - /// - Example: "Margin%" field formula "=Profit/Revenue" - /// - /// IMPORTANT LIMITATIONS: - /// - Regular PivotTables: Full support via CalculatedFields collection - /// - OLAP PivotTables: NOT SUPPORTED (use CalculatedMembers with MDX/DAX instead) - /// - For OLAP, use Data Model DAX measures via datamodel tool - /// - /// COMMON USE CASES: - /// - Financial: Profit = Revenue - Cost, Margin% = Profit/Revenue - /// - Variance: Actual - Budget, (Actual-Budget)/Budget - /// - Ratios: Cost/Unit, Revenue/Customer - /// </remarks> - PivotFieldResult CreateCalculatedField(dynamic pivot, string fieldName, string formula, string workbookPath, Microsoft.Extensions.Logging.ILogger? logger = null); - - /// <summary> - /// Sets the row layout form for the PivotTable. - /// </summary> - /// <param name="pivot">The PivotTable object</param> - /// <param name="rowLayout">Layout form: 0=Compact, 1=Tabular, 2=Outline</param> - /// <param name="workbookPath">Path to workbook for error reporting</param> - /// <param name="logger">Optional logger for diagnostics</param> - /// <returns>Result indicating success or failure</returns> - /// <remarks> - /// LAYOUT FORMS: - /// - Compact (0): All row fields in single column with indentation (Excel default) - /// - Tabular (1): Each field in separate column, subtotals at bottom - /// - Outline (2): Each field in separate column, subtotals at top - /// - /// SUPPORT: - /// - Regular PivotTables: Full support for all three forms - /// - OLAP PivotTables: Full support for all three forms - /// </remarks> - OperationResult SetLayout(dynamic pivot, int rowLayout, string workbookPath, Microsoft.Extensions.Logging.ILogger? logger = null); - - /// <summary> - /// Shows or hides subtotals for a specific row field. - /// </summary> - /// <param name="pivot">The PivotTable object</param> - /// <param name="fieldName">Name of the row field</param> - /// <param name="showSubtotals">True to show automatic subtotals, false to hide</param> - /// <param name="workbookPath">Path to workbook for error reporting</param> - /// <param name="logger">Optional logger for diagnostics</param> - /// <returns>Result with updated field configuration</returns> - /// <remarks> - /// SUBTOTALS BEHAVIOR: - /// - When enabled: Shows Automatic subtotals (uses appropriate function based on data) - /// - When disabled: Hides all subtotals for the field - /// - /// OLAP LIMITATION: - /// - OLAP PivotTables only support Automatic subtotals - /// - Regular PivotTables can choose Sum, Count, Average, etc. (future enhancement) - /// </remarks> - PivotFieldResult SetSubtotals(dynamic pivot, string fieldName, bool showSubtotals, string workbookPath, Microsoft.Extensions.Logging.ILogger? logger = null); - - /// <summary> - /// Shows or hides grand totals for rows and/or columns in the PivotTable. - /// </summary> - /// <param name="pivot">The PivotTable object</param> - /// <param name="showRowGrandTotals">True to show row grand totals, false to hide</param> - /// <param name="showColumnGrandTotals">True to show column grand totals, false to hide</param> - /// <param name="workbookPath">Path to workbook for error reporting</param> - /// <param name="logger">Optional logger for diagnostics</param> - /// <returns>Result indicating success or failure</returns> - /// <remarks> - /// GRAND TOTALS: - /// - Row Grand Totals: Summary row at the bottom showing totals across all rows - /// - Column Grand Totals: Summary column at the right showing totals across all columns - /// - Independent control: Can show/hide row and column totals separately - /// - /// SUPPORT: - /// - Regular PivotTables: Full support - /// - OLAP PivotTables: Full support - /// </remarks> - OperationResult SetGrandTotals(dynamic pivot, bool showRowGrandTotals, bool showColumnGrandTotals, string workbookPath, Microsoft.Extensions.Logging.ILogger? logger = null); -} - - diff --git a/src/ExcelMcp.Core/Commands/PivotTable/OlapPivotTableFieldStrategy.cs b/src/ExcelMcp.Core/Commands/PivotTable/OlapPivotTableFieldStrategy.cs deleted file mode 100644 index 85f4fba0..00000000 --- a/src/ExcelMcp.Core/Commands/PivotTable/OlapPivotTableFieldStrategy.cs +++ /dev/null @@ -1,1577 +0,0 @@ -using Microsoft.Extensions.Logging; -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.PivotTable; - -/// <summary> -/// Strategy for OLAP (Online Analytical Processing) PivotTable field operations. -/// Uses CubeFields API for Data Model-based PivotTables. -/// -/// CRITICAL: In OLAP PivotTables, PivotFields do not exist until the corresponding -/// CubeField is added to the PivotTable. Must call CreatePivotFields() first. -/// Reference: https://learn.microsoft.com/en-us/office/vba/api/excel.cubefield.createpivotfields -/// </summary> -public class OlapPivotTableFieldStrategy : IPivotTableFieldStrategy -{ - private static readonly char[] FieldNameSeparators = ['[', ']', '.']; - /// <inheritdoc/> - public bool CanHandle(dynamic pivot) - { - // OLAP/Data Model PivotTables have CubeFields collection - return PivotTableHelpers.IsOlapPivotTable(pivot); - } - - /// <inheritdoc/> - public dynamic GetFieldForManipulation(dynamic pivot, string fieldName) - { - dynamic? cubeFields = null; - dynamic? cubeField = null; - try - { - cubeFields = pivot.CubeFields; - - // EXACT MATCH ONLY - no partial matching to avoid disambiguation bugs - // The LLM knows exact field names, so partial matching only causes problems - // (e.g., "ACR" incorrectly matching "[DisambiguationTable].[ACRTypeKey]") - try - { - cubeField = cubeFields.Item(fieldName); - } - catch (System.Runtime.InteropServices.COMException) - { - // Field not found by exact name - return null to trigger error - cubeField = null; - } - - if (cubeField == null) - { - throw new InvalidOperationException($"Field '{fieldName}' not found in OLAP PivotTable. Use the exact CubeField name (e.g., '[Measures].[ACR]' or '[TableName].[ColumnName]')."); - } - - // CreatePivotFields() initializes PivotFields for fields not yet in the PivotTable. - // It may throw if PivotFields already exist (field already in Values area). - // Safe to ignore error - if PivotFields exist, we're good; if they don't and this fails, - // subsequent operations will provide a more specific error. - // Reference: https://learn.microsoft.com/en-us/office/vba/api/excel.cubefield.createpivotfields - try - { - cubeField.CreatePivotFields(); - } - catch (System.Runtime.InteropServices.COMException) - { - // PivotFields may already exist (field already added to PivotTable) - } - - return cubeField; // Return CubeField, not PivotField - } - catch (Exception ex) when (cubeField == null) - { - throw new InvalidOperationException($"Field '{fieldName}' not found in OLAP PivotTable. Use the exact CubeField name (e.g., '[Measures].[ACR]' or '[TableName].[ColumnName]').", ex); - } - finally - { - ComUtilities.Release(ref cubeFields); - // Note: Don't release cubeField - we're returning it - } - } - /// <inheritdoc/> - - /// <inheritdoc/> - public PivotFieldListResult ListFields(dynamic pivot, string workbookPath) - { - var fields = new List<PivotFieldInfo>(); - dynamic? cubeFields = null; - - try - { - cubeFields = pivot.CubeFields; - int fieldCount = cubeFields.Count; - - for (int i = 1; i <= fieldCount; i++) - { - dynamic? cubeField = null; - try - { - cubeField = cubeFields.Item(i); - int orientation = Convert.ToInt32(cubeField.Orientation); - - // Skip hidden fields - if (orientation == XlPivotFieldOrientation.xlHidden) - continue; - - var fieldInfo = new PivotFieldInfo - { - Name = cubeField.Name?.ToString() ?? $"Field{i}", - CustomName = cubeField.Caption?.ToString() ?? "", - Area = (PivotFieldArea)orientation, - DataType = "Cube" // OLAP fields are always Cube type - }; - - // OLAP doesn't support AvailableValues like Regular PivotTables - // Values come from OLAP dimension hierarchies - - fields.Add(fieldInfo); - } - catch (System.Runtime.InteropServices.COMException) - { - // Skip field if COM access fails - continue with other fields - } - finally - { - ComUtilities.Release(ref cubeField); - } - } - - return new PivotFieldListResult - { - Success = true, - Fields = fields, - FilePath = workbookPath - }; - } - finally - { - ComUtilities.Release(ref cubeFields); - } - } - /// <inheritdoc/> - - /// <inheritdoc/> - public PivotFieldResult AddRowField(dynamic pivot, string fieldName, int? position, string workbookPath) - { - dynamic? cubeField = null; - try - { - cubeField = GetFieldForManipulation(pivot, fieldName); - - // Check if field is already placed - int currentOrientation = Convert.ToInt32(cubeField.Orientation); - if (currentOrientation != XlPivotFieldOrientation.xlHidden) - { - throw new InvalidOperationException($"Field '{fieldName}' is already placed in {PivotTableHelpers.GetAreaName(currentOrientation)} area. Remove it first."); - } - - // CRITICAL: Set Orientation on CubeField, NOT on PivotField - cubeField.Orientation = XlPivotFieldOrientation.xlRowField; - if (position.HasValue) - { - cubeField.Position = (double)position.Value; - } - - // NOTE: No RefreshTable() needed - orientation change takes effect immediately - // RefreshTable() causes RPC disconnection on rapid operations (issue #426) - - if (cubeField.Orientation != XlPivotFieldOrientation.xlRowField) - { - throw new InvalidOperationException($"Field '{fieldName}' was not successfully added to Row area."); - } - - return new PivotFieldResult - { - Success = true, - FieldName = fieldName, - CustomName = cubeField.Caption?.ToString() ?? fieldName, - Area = PivotFieldArea.Row, - Position = Convert.ToInt32(cubeField.Position), - DataType = "Cube", - AvailableValues = [], - FilePath = workbookPath - }; - } - catch (Exception ex) - { - return new PivotFieldResult - { - Success = false, - ErrorMessage = $"Failed to add OLAP row field: {ex.Message}", - FilePath = workbookPath - }; - } - finally - { - ComUtilities.Release(ref cubeField); - } - } - /// <inheritdoc/> - - /// <inheritdoc/> - public PivotFieldResult AddColumnField(dynamic pivot, string fieldName, int? position, string workbookPath) - { - dynamic? cubeField = null; - try - { - cubeField = GetFieldForManipulation(pivot, fieldName); - - int currentOrientation = Convert.ToInt32(cubeField.Orientation); - if (currentOrientation != XlPivotFieldOrientation.xlHidden) - { - throw new InvalidOperationException($"Field '{fieldName}' is already placed in {PivotTableHelpers.GetAreaName(currentOrientation)} area. Remove it first."); - } - - cubeField.Orientation = XlPivotFieldOrientation.xlColumnField; - if (position.HasValue) - { - cubeField.Position = (double)position.Value; - } - - // NOTE: No RefreshTable() needed - orientation change takes effect immediately - // RefreshTable() causes RPC disconnection on rapid operations (issue #426) - - if (cubeField.Orientation != XlPivotFieldOrientation.xlColumnField) - { - throw new InvalidOperationException($"Field '{fieldName}' was not successfully added to Column area."); - } - - return new PivotFieldResult - { - Success = true, - FieldName = fieldName, - CustomName = cubeField.Caption?.ToString() ?? fieldName, - Area = PivotFieldArea.Column, - Position = Convert.ToInt32(cubeField.Position), - DataType = "Cube", - AvailableValues = [], - FilePath = workbookPath - }; - } - catch (Exception ex) - { - return new PivotFieldResult - { - Success = false, - ErrorMessage = $"Failed to add OLAP column field: {ex.Message}", - FilePath = workbookPath - }; - } - finally - { - ComUtilities.Release(ref cubeField); - } - } - /// <inheritdoc/> - - /// <inheritdoc/> - public PivotFieldResult AddValueField(dynamic pivot, string fieldName, AggregationFunction aggregationFunction, string? customName, string workbookPath) - { - dynamic? cubeField = null; - dynamic? workbook = null; - dynamic? model = null; - dynamic? modelTables = null; - dynamic? table = null; - dynamic? measures = null; - dynamic? newMeasure = null; - dynamic? formatObject; - - try - { - // TWO MODES: - // MODE 1: Add pre-existing measure (fieldName starts with [Measures]. or already exists in Data Model) - // MODE 2: Auto-create DAX measure from column (legacy behavior) - - // Get workbook and model - workbook = pivot.Parent.Parent; // PivotTable -> Worksheet -> Workbook - model = workbook.Model; - - if (model == null) - { - throw new InvalidOperationException( - $"Cannot add value field '{fieldName}' to OLAP PivotTable - workbook has no Data Model"); - } - - // MODE 1: Check if this is a pre-existing measure - if (IsExistingMeasure(model, fieldName, out string? existingMeasureName)) - { - // Find the measure's CubeField and add it to values area - // IMPORTANT: Use exact match to avoid disambiguation bugs (e.g., "ACR" matching "ACRTypeKey") - dynamic? cubeFields = null; - try - { - cubeFields = pivot.CubeFields; - for (int i = 1; i <= cubeFields.Count; i++) - { - dynamic? cf = null; - try - { - cf = cubeFields.Item(i); - string cfName = cf.Name?.ToString() ?? ""; - int cubeFieldType = Convert.ToInt32(cf.CubeFieldType); - - // Only match measures (CubeFieldType=2), not hierarchies (CubeFieldType=1) - // This prevents "ACR" from matching "[DisambiguationTable].[ACRTypeKey]" - if (cubeFieldType != XlCubeFieldType.xlMeasure) - continue; - - // Check for exact match: [Measures].[MeasureName] - string expectedCubeFieldName = $"[Measures].[{existingMeasureName}]"; - if (cfName.Equals(expectedCubeFieldName, StringComparison.OrdinalIgnoreCase) || - cfName.Equals(existingMeasureName, StringComparison.OrdinalIgnoreCase) || - cfName.Equals(fieldName, StringComparison.OrdinalIgnoreCase)) - { - cubeField = cf; - cf = null; // Transfer ownership - break; - } - } - finally - { - if (cf != null) - ComUtilities.Release(ref cf); - } - } - } - finally - { - ComUtilities.Release(ref cubeFields); - } - - if (cubeField == null) - { - throw new InvalidOperationException( - $"Measure '{existingMeasureName}' exists in Data Model but not found in PivotTable CubeFields. Try refreshing the PivotTable."); - } - - // Check if measure is already in values area - int currentOrientation = Convert.ToInt32(cubeField.Orientation); - if (currentOrientation == XlPivotFieldOrientation.xlDataField) - { - return new PivotFieldResult - { - Success = true, - FieldName = existingMeasureName!, - CustomName = cubeField.Caption?.ToString() ?? existingMeasureName!, - Area = PivotFieldArea.Value, - DataType = "Cube", - FilePath = workbookPath - }; - } - - // Add to values area using AddDataField (more reliable than setting Orientation directly) - // Setting cubeField.Orientation = xlDataField can fail with E_INVALIDARG (0x80070057) - // for CubeFields in certain states, while AddDataField works consistently - pivot.AddDataField(cubeField); - - return new PivotFieldResult - { - Success = true, - FieldName = existingMeasureName!, - CustomName = customName ?? cubeField.Caption?.ToString() ?? existingMeasureName!, - Area = PivotFieldArea.Value, - Function = aggregationFunction, - DataType = "Cube", - FilePath = workbookPath - }; - } - - // MODE 2: Create new measure from column (legacy auto-create behavior) - // Find the source table and column for this field - var tableAndColumn = FindTableAndColumn(pivot, fieldName); - string tableName = tableAndColumn.Item1; - string columnName = tableAndColumn.Item2; - - if (string.IsNullOrEmpty(tableName) || string.IsNullOrEmpty(columnName)) - { - throw new InvalidOperationException( - $"Cannot determine table and column for field '{fieldName}'. " + - "Field must reference a Data Model table column (e.g., 'Sales' from 'SalesTable[Sales]') " + - "OR an existing measure (e.g., '[Measures].[Total Sales]')"); - } - - // Generate DAX formula and measure name - string daxFormula = GenerateDaxFormula(tableName, columnName, aggregationFunction); - string measureName = customName ?? $"{columnName} {GetFunctionName(aggregationFunction)}"; - - // Find the table in the Data Model - modelTables = model.ModelTables; - for (int i = 1; i <= modelTables.Count; i++) - { - dynamic? t = null; - try - { - t = modelTables.Item(i); - string tName = t.Name?.ToString() ?? ""; - if (tName.Equals(tableName, StringComparison.OrdinalIgnoreCase)) - { - table = t; - t = null; // Transfer ownership - break; - } - } - finally - { - if (t != null) - ComUtilities.Release(ref t); - } - } - - if (table == null) - { - throw new InvalidOperationException($"Table '{tableName}' not found in Data Model"); - } - - // Get ModelMeasures collection and create the measure - measures = model.ModelMeasures; - formatObject = GetDefaultFormatObject(model); - - newMeasure = measures.Add( - measureName, - table, - daxFormula, - formatObject, - Type.Missing // description - ); - - // Refresh the PivotTable connection to make the measure available in CubeFields - pivot.RefreshTable(); - - // Find the measure in CubeFields - measures appear with [Measures]. prefix - // Use CubeFieldType to ensure we only match measures, not hierarchies - dynamic? cubeFieldsForNewMeasure = null; - try - { - cubeFieldsForNewMeasure = pivot.CubeFields; - for (int i = 1; i <= cubeFieldsForNewMeasure.Count; i++) - { - dynamic? cf = null; - try - { - cf = cubeFieldsForNewMeasure.Item(i); - string cfName = cf.Name?.ToString() ?? ""; - int cubeFieldType = Convert.ToInt32(cf.CubeFieldType); - - // Only match measures (CubeFieldType=2) - if (cubeFieldType != XlCubeFieldType.xlMeasure) - continue; - - // Check for exact match: [Measures].[MeasureName] - string expectedCubeFieldName = $"[Measures].[{measureName}]"; - if (cfName.Equals(expectedCubeFieldName, StringComparison.OrdinalIgnoreCase) || - cfName.Equals(measureName, StringComparison.OrdinalIgnoreCase)) - { - cubeField = cf; - cf = null; // Transfer ownership - break; - } - } - finally - { - if (cf != null) - ComUtilities.Release(ref cf); - } - } - } - finally - { - ComUtilities.Release(ref cubeFieldsForNewMeasure); - } - - if (cubeField == null) - { - throw new InvalidOperationException($"Measure '{measureName}' created but not found in PivotTable CubeFields after refresh"); - } - - // Add to values area using AddDataField (more reliable than setting Orientation directly) - pivot.AddDataField(cubeField); - - return new PivotFieldResult - { - Success = true, - FieldName = measureName, - CustomName = customName ?? measureName, - Area = PivotFieldArea.Value, - Function = aggregationFunction, - DataType = "Cube", - FilePath = workbookPath - }; - } - catch (Exception ex) - { - return new PivotFieldResult - { - Success = false, - ErrorMessage = ex.Message, - FilePath = workbookPath - }; - } - finally - { - // Don't release formatObject - it's owned by the model - ComUtilities.Release(ref newMeasure); - ComUtilities.Release(ref measures); - ComUtilities.Release(ref table); - ComUtilities.Release(ref modelTables); - ComUtilities.Release(ref model); - ComUtilities.Release(ref workbook); - ComUtilities.Release(ref cubeField); - } - } - /// <inheritdoc/> - - /// <inheritdoc/> - public PivotFieldResult AddFilterField(dynamic pivot, string fieldName, string workbookPath) - { - dynamic? cubeField = null; - try - { - cubeField = GetFieldForManipulation(pivot, fieldName); - - int currentOrientation = Convert.ToInt32(cubeField.Orientation); - if (currentOrientation != XlPivotFieldOrientation.xlHidden) - { - throw new InvalidOperationException($"Field '{fieldName}' is already placed in {PivotTableHelpers.GetAreaName(currentOrientation)} area. Remove it first."); - } - - cubeField.Orientation = XlPivotFieldOrientation.xlPageField; - - // NOTE: No RefreshTable() needed - orientation change takes effect immediately - // RefreshTable() causes RPC disconnection on rapid operations (issue #426) - - if (cubeField.Orientation != XlPivotFieldOrientation.xlPageField) - { - throw new InvalidOperationException($"Field '{fieldName}' was not successfully added to Filter area."); - } - - return new PivotFieldResult - { - Success = true, - FieldName = fieldName, - CustomName = cubeField.Caption?.ToString() ?? fieldName, - Area = PivotFieldArea.Filter, - Position = Convert.ToInt32(cubeField.Position), - DataType = "Cube", - AvailableValues = [], - FilePath = workbookPath - }; - } - catch (Exception ex) - { - return new PivotFieldResult - { - Success = false, - ErrorMessage = $"Failed to add OLAP filter field: {ex.Message}", - FilePath = workbookPath - }; - } - finally - { - ComUtilities.Release(ref cubeField); - } - } - /// <inheritdoc/> - - /// <inheritdoc/> - public PivotFieldResult RemoveField(dynamic pivot, string fieldName, string workbookPath) - { - dynamic? cubeField = null; - try - { - cubeField = GetFieldForManipulation(pivot, fieldName); - - int currentOrientation = Convert.ToInt32(cubeField.Orientation); - if (currentOrientation == XlPivotFieldOrientation.xlHidden) - { - throw new InvalidOperationException($"Field '{fieldName}' is not currently placed in any area"); - } - - cubeField.Orientation = XlPivotFieldOrientation.xlHidden; - - // NOTE: No RefreshTable() needed - orientation change takes effect immediately - // RefreshTable() causes RPC disconnection on rapid operations (issue #426) - - return new PivotFieldResult - { - Success = true, - FieldName = fieldName, - Area = PivotFieldArea.Hidden, - FilePath = workbookPath - }; - } - catch (Exception ex) - { - return new PivotFieldResult - { - Success = false, - ErrorMessage = $"Failed to remove OLAP field: {ex.Message}", - FilePath = workbookPath - }; - } - finally - { - ComUtilities.Release(ref cubeField); - } - } - /// <inheritdoc/> - - /// <inheritdoc/> - public PivotFieldResult SetFieldName(dynamic pivot, string fieldName, string customName, string workbookPath) - { - dynamic? cubeField = null; - try - { - // OLAP limitation: Cannot set Caption on CubeFields via COM - throw new InvalidOperationException( - $"Cannot rename OLAP field '{fieldName}' to '{customName}'. " + - "Field names in OLAP PivotTables are derived from the Data Model definition. " + - "To change field names: (1) Open Data Model in Excel, (2) Rename the dimension/hierarchy, (3) Refresh the PivotTable. " + - "Reference: https://learn.microsoft.com/en-us/excel/vba/api/excel.cubefield.caption"); - } - catch (Exception ex) - { - return new PivotFieldResult - { - Success = false, - ErrorMessage = ex.Message, - FilePath = workbookPath - }; - } - finally - { - ComUtilities.Release(ref cubeField); - } - } - /// <inheritdoc/> - - /// <inheritdoc/> - public PivotFieldResult SetFieldFunction(dynamic pivot, string fieldName, AggregationFunction aggregationFunction, string workbookPath) - { - dynamic? workbook = null; - dynamic? model = null; - dynamic? measures = null; - dynamic? measure = null; - try - { - // For OLAP PivotTables, we need to update the DAX measure in the Data Model - // Get workbook and model - workbook = pivot.Parent.Parent; - model = workbook.Model; - - if (model == null) - { - throw new InvalidOperationException( - $"Cannot update measure '{fieldName}' - workbook has no Data Model"); - } - - // Normalize field name - extract measure name from [Measures].[Name] format if present - string targetMeasureName = NormalizeMeasureName(fieldName); - - // Find the measure by name - measures = model.ModelMeasures; - for (int i = 1; i <= measures.Count; i++) - { - dynamic? m = null; - try - { - m = measures.Item(i); - string mName = m.Name?.ToString() ?? ""; - if (mName.Equals(targetMeasureName, StringComparison.OrdinalIgnoreCase)) - { - measure = m; - m = null; // Transfer ownership - break; - } - } - finally - { - if (m != null) - ComUtilities.Release(ref m); - } - } - - if (measure == null) - { - throw new InvalidOperationException($"Measure '{fieldName}' not found in Data Model"); - } - - // Parse the current formula to extract table and column - string currentFormula = measure.Formula?.ToString() ?? ""; - var parsedFormula = ParseDaxFormula(currentFormula); - - if (string.IsNullOrEmpty(parsedFormula.tableName) || string.IsNullOrEmpty(parsedFormula.columnName)) - { - throw new InvalidOperationException( - $"Cannot update measure '{fieldName}' - unable to parse current formula: {currentFormula}"); - } - - // Generate new DAX formula with the new aggregation function - string newFormula = GenerateDaxFormula(parsedFormula.tableName, parsedFormula.columnName, aggregationFunction); - - // Update the measure's formula - measure.Formula = newFormula; - - // NOTE: No RefreshTable() needed - formula change takes effect immediately - // RefreshTable() causes RPC disconnection on rapid operations (issue #426) - - return new PivotFieldResult - { - Success = true, - FieldName = fieldName, - Function = aggregationFunction, - DataType = "Cube", - FilePath = workbookPath - }; - } - catch (Exception ex) - { - return new PivotFieldResult - { - Success = false, - ErrorMessage = ex.Message, - FilePath = workbookPath - }; - } - finally - { - ComUtilities.Release(ref measure); - ComUtilities.Release(ref measures); - ComUtilities.Release(ref model); - ComUtilities.Release(ref workbook); - } - } - /// <inheritdoc/> - - /// <inheritdoc/> - public PivotFieldResult SetFieldFormat(dynamic pivot, string fieldName, string numberFormat, string workbookPath) - { - dynamic? cubeField = null; - dynamic? pivotFields = null; - dynamic? pivotField = null; - try - { - // For OLAP PivotTables, find the CubeField and set NumberFormat on its PivotField - // This works for measures in the Values area (including [Measures].[Name] format) - cubeField = GetFieldForManipulation(pivot, fieldName); - - // Verify the field is in the Values area (only data fields can have number formats) - int orientation = Convert.ToInt32(cubeField.Orientation); - if (orientation != XlPivotFieldOrientation.xlDataField) - { - throw new InvalidOperationException( - $"Field '{fieldName}' is not in the Values area (Orientation={orientation}). " + - "Only value fields can have number formats."); - } - - // Access the PivotFields collection to set the NumberFormat - // OLAP CubeFields expose their formatting through PivotFields - pivotFields = cubeField.PivotFields; - if (pivotFields == null || pivotFields.Count == 0) - { - throw new InvalidOperationException( - $"Cannot format OLAP field '{fieldName}' - PivotFields not available. " + - "Ensure the field has been added to the Values area."); - } - - // Get the first (and typically only) PivotField and set its NumberFormat - pivotField = pivotFields.Item(1); - pivotField.NumberFormat = numberFormat; - - // NOTE: No RefreshTable() needed - NumberFormat is a visual-only property - // RefreshTable() would re-query the Data Model which is very slow for OLAP PivotTables - - // Read back the format to verify it was set - string? appliedFormat = null; - try - { - appliedFormat = pivotField.NumberFormat?.ToString(); - } - catch (System.Runtime.InteropServices.COMException) - { - // If we can't read it back, use what we set - appliedFormat = numberFormat; - } - - return new PivotFieldResult - { - Success = true, - FieldName = fieldName, - CustomName = cubeField.Caption?.ToString() ?? fieldName, - Area = PivotFieldArea.Value, - NumberFormat = appliedFormat, - DataType = "Cube", - FilePath = workbookPath - }; - } - finally - { - ComUtilities.Release(ref pivotField); - ComUtilities.Release(ref pivotFields); - ComUtilities.Release(ref cubeField); - } - } - /// <inheritdoc/> - - /// <inheritdoc/> - public PivotFieldFilterResult SetFieldFilter(dynamic pivot, string fieldName, List<string> filterValues, string workbookPath) - { - dynamic? cubeField = null; - dynamic? pivotFields = null; - dynamic? pivotField = null; - dynamic? pivotItems = null; - try - { - // OLAP limitation: Cannot set Visible property on OLAP PivotItems - throw new InvalidOperationException( - $"Cannot filter OLAP field '{fieldName}' via PivotItem.Visible property. " + - "OLAP PivotItems do not support the Visible property. " + - "To filter OLAP data: (1) Use PivotTable's built-in filter buttons in Excel, (2) Use OLAP Slicers for interactive filtering, or (3) Modify the source Data Model. " + - "Reference: https://learn.microsoft.com/en-us/excel/vba/api/excel.pivotitem.visible"); - } - catch (Exception ex) - { - return new PivotFieldFilterResult - { - Success = false, - ErrorMessage = ex.Message, - FilePath = workbookPath - }; - } - finally - { - ComUtilities.Release(ref pivotItems); - ComUtilities.Release(ref pivotField); - ComUtilities.Release(ref pivotFields); - ComUtilities.Release(ref cubeField); - } - } - /// <inheritdoc/> - - /// <inheritdoc/> - public PivotFieldResult SortField(dynamic pivot, string fieldName, SortDirection direction, string workbookPath) - { - dynamic? cubeField = null; - dynamic? pivotFields = null; - dynamic? pivotField = null; - try - { - cubeField = GetFieldForManipulation(pivot, fieldName); - - // OLAP sorting works through PivotField, not CubeField - pivotFields = cubeField.PivotFields; - if (pivotFields == null || pivotFields.Count == 0) - { - throw new InvalidOperationException($"Cannot sort OLAP field '{fieldName}' - PivotFields not available"); - } - - pivotField = pivotFields.Item(1); - - int sortOrder = direction == SortDirection.Ascending - ? XlSortOrder.xlAscending - : XlSortOrder.xlDescending; - - pivotField.AutoSort(sortOrder, fieldName); - - // NOTE: No RefreshTable() needed - Sorting is a visual-only operation - - return new PivotFieldResult - { - Success = true, - FieldName = fieldName, - CustomName = cubeField.Caption?.ToString() ?? fieldName, - Area = (PivotFieldArea)cubeField.Orientation, - FilePath = workbookPath - }; - } - catch (Exception ex) - { - return new PivotFieldResult - { - Success = false, - ErrorMessage = $"Failed to sort OLAP field: {ex.Message}", - FilePath = workbookPath - }; - } - finally - { - ComUtilities.Release(ref pivotField); - ComUtilities.Release(ref pivotFields); - ComUtilities.Release(ref cubeField); - } - } - - /// <summary> - /// Group a date/time field by the specified interval (Month, Quarter, Year). - /// OLAP CubeFields automatically create date hierarchies from Data Model columns. - /// Manual grouping via Group() is NOT supported for OLAP PivotTables. - /// </summary> - public PivotFieldResult GroupByDate(dynamic pivot, string fieldName, DateGroupingInterval interval, string workbookPath, ILogger? logger = null) - { - dynamic? cubeField = null; - try - { - cubeField = GetFieldForManipulation(pivot, fieldName); - - // OLAP PivotTables do not support manual date grouping via LabelRange.Group() - // Date hierarchies are defined in the Data Model and automatically available - return new PivotFieldResult - { - Success = false, - ErrorMessage = $"Manual date grouping is not supported for OLAP PivotTables. " + - $"Date hierarchies must be defined in the Data Model. " + - $"Use Power Pivot to create date hierarchies (Year > Quarter > Month > Day) on the '{fieldName}' column.", - FieldName = fieldName, - FilePath = workbookPath, - WorkflowHint = "For OLAP PivotTables: 1) Open Power Pivot, 2) Create date hierarchy on date column, " + - "3) Use RemoveField/AddField to place hierarchy levels in PivotTable areas." - }; - } - catch (Exception ex) - { - return new PivotFieldResult - { - Success = false, - ErrorMessage = $"Failed to access OLAP field '{fieldName}': {ex.Message}", - FilePath = workbookPath - }; - } - finally - { - ComUtilities.Release(ref cubeField); - } - } - - /// <inheritdoc/> - public PivotFieldResult GroupByNumeric(dynamic pivot, string fieldName, double? start, double? endValue, double intervalSize, string workbookPath, ILogger? logger = null) - { - dynamic? cubeField = null; - try - { - cubeField = GetFieldForManipulation(pivot, fieldName); - - // OLAP PivotTables do not support manual numeric grouping via LabelRange.Group() - // Numeric grouping must be done in the source data or Data Model - return new PivotFieldResult - { - Success = false, - ErrorMessage = $"Manual numeric grouping is not supported for OLAP PivotTables. " + - $"Numeric grouping must be defined in the Data Model. " + - $"Use Power Pivot to create calculated columns with range logic on the '{fieldName}' column.", - FieldName = fieldName, - FilePath = workbookPath, - WorkflowHint = "For OLAP PivotTables: 1) Open Power Pivot, 2) Create calculated column with range logic " + - "(e.g., IF([Sales]<100, \"0-100\", IF([Sales]<200, \"100-200\", ...))), 3) Use that calculated column in PivotTable." - }; - } - catch (Exception ex) - { - return new PivotFieldResult - { - Success = false, - ErrorMessage = $"Failed to access OLAP field '{fieldName}': {ex.Message}", - FilePath = workbookPath - }; - } - finally - { - ComUtilities.Release(ref cubeField); - } - } - - /// <inheritdoc/> - public PivotFieldResult CreateCalculatedField(dynamic pivot, string fieldName, string formula, string workbookPath, ILogger? logger = null) - { - // CRITICAL: OLAP PivotTables do NOT support CalculatedFields collection - // The CalculatedFields collection returns Nothing for OLAP PivotTables - // OLAP uses CalculatedMembers with MDX/DAX formulas instead - // - // Reference: https://learn.microsoft.com/en-us/office/vba/api/excel.pivottable.calculatedfields - // "For OLAP data sources, you cannot set this collection, and it always returns Nothing" - return new PivotFieldResult - { - Success = false, - FieldName = fieldName, - Formula = formula, - ErrorMessage = "Calculated fields are not supported for OLAP PivotTables. " + - "OLAP PivotTables use CalculatedMembers with MDX/DAX formulas instead. " + - "For Data Model PivotTables, use DAX measures via datamodel tool.", - FilePath = workbookPath, - WorkflowHint = "For OLAP/Data Model PivotTables: " + - "1) Use datamodel tool to create DAX measures with formulas, " + - "2) Refresh PivotTable to see new measures in field list, " + - "3) Add measure to Values area with AddValueField. " + - "Example DAX: Profit = SUM('Sales'[Revenue]) - SUM('Sales'[Cost])" - }; - } - /// <inheritdoc/> - public OperationResult SetLayout(dynamic pivot, int rowLayout, string workbookPath, ILogger? logger = null) - { - // OLAP PivotTables support all three layout forms - // xlCompactRow=0, xlTabularRow=1, xlOutlineRow=2 - pivot.RowAxisLayout(rowLayout); - - // NOTE: No RefreshTable() needed - Layout is a visual-only property - - if (logger?.IsEnabled(LogLevel.Information) is true) - { - logger.LogInformation("Set OLAP PivotTable layout to {LayoutType}", rowLayout); - } - - return new OperationResult - { - Success = true, - FilePath = workbookPath - }; - } - /// <inheritdoc/> - public PivotFieldResult SetSubtotals( - dynamic pivot, - string fieldName, - bool showSubtotals, - string workbookPath, - ILogger? logger = null) - { - dynamic? field = null; - try - { - // Get the field - for OLAP, use PivotFields (not CubeFields) - dynamic pivotFields = pivot.PivotFields; - field = pivotFields.Item(fieldName); - - // OLAP PivotTables only support Automatic subtotals (index 1) - // Other subtotal types not available for OLAP data sources - field.Subtotals[1] = showSubtotals; - - // NOTE: No RefreshTable() needed - Subtotals is a visual-only property - - if (logger?.IsEnabled(LogLevel.Information) is true) - { - logger.LogInformation("Set OLAP subtotals for field {FieldName} to {ShowSubtotals}", fieldName, showSubtotals); - } - - return new PivotFieldResult - { - Success = true, - FieldName = fieldName, - FilePath = workbookPath, - WorkflowHint = showSubtotals - ? "Subtotals enabled for OLAP field. Only Automatic function supported (OLAP limitation)." - : "Subtotals disabled for OLAP field." - }; - } - catch (Exception ex) - { - if (logger?.IsEnabled(LogLevel.Error) is true) - { - logger.LogError(ex, "SetSubtotals failed for OLAP field {FieldName}", fieldName); - } - return new PivotFieldResult - { - Success = false, - FieldName = fieldName, - ErrorMessage = $"Failed to set OLAP subtotals: {ex.Message}", - FilePath = workbookPath - }; - } - finally - { - ComUtilities.Release(ref field); - } - } - /// <inheritdoc/> - public OperationResult SetGrandTotals(dynamic pivot, bool showRowGrandTotals, bool showColumnGrandTotals, string workbookPath, ILogger? logger = null) - { - pivot.RowGrand = showRowGrandTotals; - pivot.ColumnGrand = showColumnGrandTotals; - - // NOTE: No RefreshTable() needed - GrandTotals are visual-only properties - - if (logger is not null && logger.IsEnabled(LogLevel.Information)) - { - logger.LogInformation("Set OLAP grand totals: Row={RowGrand}, Column={ColumnGrand}", showRowGrandTotals, showColumnGrandTotals); - } - - return new OperationResult - { - Success = true, - FilePath = workbookPath - }; - } - - #region Helper Methods - - /// <summary> - /// Find the source table and column for a CubeField in the Data Model. - /// OLAP CubeFields reference Data Model columns in format: [TableName].[ColumnName] - /// NOTE: This only searches hierarchy fields (CubeFieldType=1), not measures. - /// </summary> - private static (string tableName, string columnName) FindTableAndColumn(dynamic pivot, string fieldName) - { - dynamic? cubeFields = null; - try - { - cubeFields = pivot.CubeFields; - - // Try to find the CubeField matching fieldName - // Only look at hierarchies (table columns), not measures - for (int i = 1; i <= cubeFields.Count; i++) - { - dynamic? cf = null; - try - { - cf = cubeFields.Item(i); - string cfName = cf.Name?.ToString() ?? ""; - int cubeFieldType = Convert.ToInt32(cf.CubeFieldType); - - // Skip measures - we're looking for table columns only - if (cubeFieldType == XlCubeFieldType.xlMeasure) - continue; - - // EXACT MATCH ONLY - no partial matching - if (cfName.Equals(fieldName, StringComparison.OrdinalIgnoreCase)) - { - // Parse hierarchical name format: [TableName].[ColumnName] - // Example: "[RegionalSalesTable].[Sales]" -> table="RegionalSalesTable", column="Sales" - if (cfName.Contains('[') && cfName.Contains(']')) - { - var parts = cfName.Split(FieldNameSeparators, StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 2) - { - return (parts[0], parts[1]); - } - } - - // Fallback: If no hierarchical format, assume fieldName is the column - // and try to infer table from the CubeField's SourceName property - try - { - string sourceName = cf.SourceName?.ToString() ?? ""; - if (!string.IsNullOrEmpty(sourceName) && sourceName.Contains('[')) - { - var sourceParts = sourceName.Split(FieldNameSeparators, StringSplitOptions.RemoveEmptyEntries); - if (sourceParts.Length >= 2) - { - return (sourceParts[0], sourceParts[1]); - } - } - } - catch (System.Runtime.InteropServices.COMException) - { - // SourceName property might not be available for this CubeField type - } - } - } - finally - { - ComUtilities.Release(ref cf); - } - } - - return (string.Empty, string.Empty); - } - finally - { - ComUtilities.Release(ref cubeFields); - } - } - - /// <summary> - /// Generate DAX formula for a measure based on aggregation function. - /// Examples: - /// - SUM: SUM('TableName'[ColumnName]) - /// - COUNT: COUNT('TableName'[ColumnName]) - /// - AVERAGE: AVERAGE('TableName'[ColumnName]) - /// </summary> - private static string GenerateDaxFormula(string tableName, string columnName, AggregationFunction function) - { - string daxFunction = function switch - { - AggregationFunction.Sum => "SUM", - AggregationFunction.Count => "COUNT", - AggregationFunction.Average => "AVERAGE", - AggregationFunction.Max => "MAX", - AggregationFunction.Min => "MIN", - AggregationFunction.CountNumbers => "COUNT", - AggregationFunction.StdDev => "STDEV.S", - AggregationFunction.StdDevP => "STDEV.P", - AggregationFunction.Var => "VAR.S", - AggregationFunction.VarP => "VAR.P", - _ => throw new InvalidOperationException($"Unsupported aggregation function for DAX: {function}") - }; - - // DAX syntax: FUNCTION('TableName'[ColumnName]) - return $"{daxFunction}('{tableName}'[{columnName}])"; - } - - /// <summary> - /// Get friendly function name for measure naming. - /// </summary> - private static string GetFunctionName(AggregationFunction function) - { - return function switch - { - AggregationFunction.Sum => "Sum", - AggregationFunction.Count => "Count", - AggregationFunction.Average => "Average", - AggregationFunction.Max => "Max", - AggregationFunction.Min => "Min", - AggregationFunction.CountNumbers => "Count", - AggregationFunction.StdDev => "StdDev", - AggregationFunction.StdDevP => "StdDevP", - AggregationFunction.Var => "Var", - AggregationFunction.VarP => "VarP", - _ => function.ToString() - }; - } - - /// <summary> - /// Get default format object from Data Model. - /// Returns ModelFormatGeneral for standard numeric display. - /// </summary> - private static dynamic GetDefaultFormatObject(dynamic model) - { - // Get default format - ModelFormatGeneral is always available - dynamic formats = model.ModelFormatGeneral; - return formats; - } - - /// <summary> - /// Parse DAX formula to extract table and column names. - /// Handles formats like: SUM('TableName'[ColumnName]), COUNT('Table'[Column]), etc. - /// </summary> - private static (string tableName, string columnName) ParseDaxFormula(string daxFormula) - { - // Expected format: FUNCTION('TableName'[ColumnName]) - // Extract table name from single quotes - int tableStart = daxFormula.IndexOf('\''); - int tableEnd = daxFormula.IndexOf('\'', tableStart + 1); - - if (tableStart == -1 || tableEnd == -1) - { - return (string.Empty, string.Empty); - } - - string tableName = daxFormula.Substring(tableStart + 1, tableEnd - tableStart - 1); - - // Extract column name from square brackets - int columnStart = daxFormula.IndexOf('[', tableEnd); - int columnEnd = daxFormula.IndexOf(']', columnStart + 1); - - if (columnStart == -1 || columnEnd == -1) - { - return (string.Empty, string.Empty); - } - - string columnName = daxFormula.Substring(columnStart + 1, columnEnd - columnStart - 1); - - return (tableName, columnName); - } - - /// <summary> - /// Parse number format string and create appropriate ModelFormat object. - /// Supports: currency, percentage, decimal, whole number, general. - /// </summary> - private static dynamic? GetModelFormatObject(dynamic model, string numberFormat) - { - // Currency formats: $#,##0.00, $#,##0, etc. - if (numberFormat.Contains('$')) - { - dynamic? currencyFormat = null; - try - { - currencyFormat = model.ModelFormatCurrency; - - // Parse decimal places from format string - int decimalIndex = numberFormat.IndexOf('.'); - if (decimalIndex >= 0) - { - // Count zeros after decimal point - int decimalPlaces = 0; - for (int i = decimalIndex + 1; i < numberFormat.Length && numberFormat[i] == '0'; i++) - { - decimalPlaces++; - } - currencyFormat.DecimalPlaces = decimalPlaces; - } - else - { - currencyFormat.DecimalPlaces = 0; - } - - currencyFormat.Symbol = "$"; - return currencyFormat; - } - catch (System.Runtime.InteropServices.COMException) - { - if (currencyFormat != null) - ComUtilities.Release(ref currencyFormat); - throw; - } - } - - // Percentage formats: 0.00%, 0%, etc. - if (numberFormat.Contains('%')) - { - dynamic? percentFormat = null; - try - { - percentFormat = model.ModelFormatPercentageNumber; - - // Parse decimal places - int decimalIndex = numberFormat.IndexOf('.'); - if (decimalIndex >= 0) - { - int decimalPlaces = 0; - for (int i = decimalIndex + 1; i < numberFormat.Length && numberFormat[i] == '0'; i++) - { - decimalPlaces++; - } - percentFormat.DecimalPlaces = decimalPlaces; - } - else - { - percentFormat.DecimalPlaces = 0; - } - - return percentFormat; - } - catch (System.Runtime.InteropServices.COMException) - { - if (percentFormat != null) - ComUtilities.Release(ref percentFormat); - throw; - } - } - - // Decimal number formats: 0.00, #,##0.00, etc. - if (numberFormat.Contains('.')) - { - dynamic? decimalFormat = null; - try - { - decimalFormat = model.ModelFormatDecimalNumber; - - // Parse decimal places - int decimalIndex = numberFormat.IndexOf('.'); - int decimalPlaces = 0; - for (int i = decimalIndex + 1; i < numberFormat.Length && (numberFormat[i] == '0' || numberFormat[i] == '#'); i++) - { - decimalPlaces++; - } - decimalFormat.DecimalPlaces = decimalPlaces; - - // Check for thousand separator - if (numberFormat.Contains(',')) - { - decimalFormat.UseThousandSeparator = true; - } - - return decimalFormat; - } - catch (System.Runtime.InteropServices.COMException) - { - if (decimalFormat != null) - ComUtilities.Release(ref decimalFormat); - throw; - } - } - - // Whole number formats: 0, #,##0, etc. - if (numberFormat.Contains('0') || numberFormat.Contains('#')) - { - dynamic? wholeFormat = null; - try - { - wholeFormat = model.ModelFormatWholeNumber; - - // Check for thousand separator - if (numberFormat.Contains(',')) - { - wholeFormat.UseThousandSeparator = true; - } - - return wholeFormat; - } - catch (System.Runtime.InteropServices.COMException) - { - if (wholeFormat != null) - ComUtilities.Release(ref wholeFormat); - throw; - } - } - - // Default: General format - return model.ModelFormatGeneral; - } - - /// <summary> - /// Modify an existing format object's properties based on the format string. - /// The format object is already attached to a measure and we modify it in place. - /// </summary> - private static void ModifyFormatObject(dynamic formatObject, string numberFormat) - { - // Try to determine the format type and modify accordingly - // Currency format - if (numberFormat.Contains('$')) - { - try - { - // Parse decimal places - int decimalIndex = numberFormat.IndexOf('.'); - if (decimalIndex >= 0) - { - int decimalPlaces = 0; - for (int i = decimalIndex + 1; i < numberFormat.Length && numberFormat[i] == '0'; i++) - { - decimalPlaces++; - } - formatObject.DecimalPlaces = decimalPlaces; - } - else - { - formatObject.DecimalPlaces = 0; - } - - formatObject.Symbol = "$"; - return; - } - catch (Exception ex) when (ex is System.Runtime.InteropServices.COMException or Microsoft.CSharp.RuntimeBinder.RuntimeBinderException) - { - // Format object doesn't support currency properties - fall through to try other types - } - } - - // Percentage format - if (numberFormat.Contains('%')) - { - try - { - int decimalIndex = numberFormat.IndexOf('.'); - if (decimalIndex >= 0) - { - int decimalPlaces = 0; - for (int i = decimalIndex + 1; i < numberFormat.Length && numberFormat[i] == '0'; i++) - { - decimalPlaces++; - } - formatObject.DecimalPlaces = decimalPlaces; - } - else - { - formatObject.DecimalPlaces = 0; - } - return; - } - catch (Exception ex) when (ex is System.Runtime.InteropServices.COMException or Microsoft.CSharp.RuntimeBinder.RuntimeBinderException) - { - // Format object doesn't support percentage properties - fall through - } - } - - // Decimal number format - if (numberFormat.Contains('.')) - { - try - { - int decimalIndex = numberFormat.IndexOf('.'); - int decimalPlaces = 0; - for (int i = decimalIndex + 1; i < numberFormat.Length && (numberFormat[i] == '0' || numberFormat[i] == '#'); i++) - { - decimalPlaces++; - } - formatObject.DecimalPlaces = decimalPlaces; - - if (numberFormat.Contains(',')) - { - formatObject.UseThousandSeparator = true; - } - return; - } - catch (Exception ex) when (ex is System.Runtime.InteropServices.COMException or Microsoft.CSharp.RuntimeBinder.RuntimeBinderException) - { - // Format object doesn't support decimal properties - fall through - } - } - - // Whole number format - if (numberFormat.Contains('0') || numberFormat.Contains('#')) - { - try - { - if (numberFormat.Contains(',')) - { - formatObject.UseThousandSeparator = true; - } - return; - } - catch (Exception ex) when (ex is System.Runtime.InteropServices.COMException or Microsoft.CSharp.RuntimeBinder.RuntimeBinderException) - { - // Format object doesn't support whole number properties - fall through - } - } - - // If we get here, it's probably ModelFormatGeneral which has no configurable properties - } - - /// <summary> - /// Normalize measure name by extracting it from [Measures].[Name] format if present. - /// Returns the bare measure name (e.g., "Total Sales" from "[Measures].[Total Sales]"). - /// </summary> - private static string NormalizeMeasureName(string fieldName) - { - if (fieldName.StartsWith("[Measures].", StringComparison.OrdinalIgnoreCase)) - { - // Remove [Measures]. prefix and extract name from brackets - var parts = fieldName.Split(FieldNameSeparators, StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 2) - { - return parts[1]; // "Total Sales" from "[Measures].[Total Sales]" - } - } - return fieldName; - } - - /// <summary> - /// Check if fieldName refers to an existing measure in the Data Model. - /// Returns true if the measure exists, and outputs the measure name. - /// Handles formats: "[Measures].[Name]" or "Name" (exact match only). - /// </summary> - private static bool IsExistingMeasure(dynamic model, string fieldName, out string? measureName) - { - measureName = null; - dynamic? measures = null; - try - { - measures = model.ModelMeasures; - if (measures == null || measures.Count == 0) - { - return false; - } - - // Extract measure name from [Measures].[Name] format if present - string searchName = NormalizeMeasureName(fieldName); - - // Search for measure by name (EXACT match only - no partial matching) - // Partial matching causes disambiguation bugs where "ACR" could match "ACRTypeKey" - for (int i = 1; i <= measures.Count; i++) - { - dynamic? measure = null; - try - { - measure = measures.Item(i); - string mName = measure.Name?.ToString() ?? ""; - - // Exact match only - no Contains() to avoid false positives - if (mName.Equals(searchName, StringComparison.OrdinalIgnoreCase) || - mName.Equals(fieldName, StringComparison.OrdinalIgnoreCase)) - { - measureName = mName; - return true; - } - } - finally - { - ComUtilities.Release(ref measure); - } - } - - return false; - } - finally - { - ComUtilities.Release(ref measures); - } - } - - #endregion -} - - - diff --git a/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.Analysis.cs b/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.Analysis.cs deleted file mode 100644 index 320f3ccd..00000000 --- a/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.Analysis.cs +++ /dev/null @@ -1,78 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.PivotTable; - -/// <summary> -/// PivotTable analysis operations (GetData, SetFieldFilter, SortField) -/// </summary> -public partial class PivotTableCommands -{ - /// <summary> - /// Gets the current data from a PivotTable - /// </summary> - public PivotTableDataResult GetData(IExcelBatch batch, string pivotTableName) - { - return batch.Execute((ctx, ct) => - { - dynamic? pivot = null; - dynamic? tableRange = null; - - try - { - pivot = FindPivotTable(ctx.Book, pivotTableName); - tableRange = pivot.TableRange2; - - // Get the data range - object[,] values = tableRange.Value2; - - // Convert to List<List<object?>> - var dataList = new List<List<object?>>(); - for (int row = 1; row <= values.GetLength(0); row++) - { - var rowList = new List<object?>(); - for (int col = 1; col <= values.GetLength(1); col++) - { - rowList.Add(values[row, col]); - } - dataList.Add(rowList); - } - - return new PivotTableDataResult - { - Success = true, - PivotTableName = pivotTableName, - Values = dataList, - DataRowCount = values.GetLength(0), - DataColumnCount = values.GetLength(1), - FilePath = batch.WorkbookPath - }; - } - finally - { - ComUtilities.Release(ref tableRange); - ComUtilities.Release(ref pivot); - } - }); - } - - /// <summary> - /// Sets filter for a field - /// </summary> - public PivotFieldFilterResult SetFieldFilter(IExcelBatch batch, string pivotTableName, - string fieldName, List<string> selectedValues) - => ExecuteWithStrategy<PivotFieldFilterResult>(batch, pivotTableName, - (strategy, pivot) => strategy.SetFieldFilter(pivot, fieldName, selectedValues, batch.WorkbookPath)); - - /// <summary> - /// Sorts a field - /// </summary> - public PivotFieldResult SortField(IExcelBatch batch, string pivotTableName, - string fieldName, SortDirection direction = SortDirection.Ascending) - => ExecuteWithStrategy<PivotFieldResult>(batch, pivotTableName, - (strategy, pivot) => strategy.SortField(pivot, fieldName, direction, batch.WorkbookPath)); -} - - - diff --git a/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.CalculatedFields.cs b/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.CalculatedFields.cs deleted file mode 100644 index 117dda20..00000000 --- a/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.CalculatedFields.cs +++ /dev/null @@ -1,165 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.PivotTable; - -/// <summary> -/// Calculated Fields operations for PivotTableCommands. -/// Creates custom fields with formulas for Regular PivotTables. -/// OLAP PivotTables use DAX measures instead (see datamodel tool). -/// </summary> -public partial class PivotTableCommands -{ - /// <inheritdoc/> - public PivotFieldResult CreateCalculatedField(IExcelBatch batch, string pivotTableName, - string fieldName, string formula) - => ExecuteWithStrategy<PivotFieldResult>(batch, pivotTableName, - (strategy, pivot) => strategy.CreateCalculatedField(pivot, fieldName, formula, batch.WorkbookPath, batch.Logger)); - - /// <inheritdoc/> - public CalculatedFieldListResult ListCalculatedFields(IExcelBatch batch, string pivotTableName) - { - return batch.Execute((ctx, ct) => - { - dynamic? pivot = null; - dynamic? calculatedFields = null; - - try - { - pivot = FindPivotTable(ctx.Book, pivotTableName); - - // Check if this is an OLAP PivotTable - they don't support calculated fields - if (PivotTableHelpers.IsOlapPivotTable(pivot)) - { - return new CalculatedFieldListResult - { - Success = false, - ErrorMessage = $"PivotTable '{pivotTableName}' is an OLAP PivotTable. OLAP PivotTables do not support calculated fields. Use list-calculated-members instead for Data Model PivotTables." - }; - } - - var result = new CalculatedFieldListResult - { - Success = true - }; - - // Get CalculatedFields collection - calculatedFields = pivot.CalculatedFields(); - - int count = calculatedFields.Count; - for (int i = 1; i <= count; i++) - { - dynamic? field = null; - try - { - field = calculatedFields.Item(i); - - var fieldInfo = new CalculatedFieldInfo - { - Name = field.Name?.ToString() ?? string.Empty, - Formula = field.Formula?.ToString() ?? string.Empty, - SourceName = field.SourceName?.ToString() - }; - - result.CalculatedFields.Add(fieldInfo); - } - finally - { - ComUtilities.Release(ref field); - } - } - - return result; - } - finally - { - ComUtilities.Release(ref calculatedFields); - ComUtilities.Release(ref pivot); - } - }); - } - - /// <inheritdoc/> - public OperationResult DeleteCalculatedField(IExcelBatch batch, string pivotTableName, string fieldName) - { - return batch.Execute((ctx, ct) => - { - dynamic? pivot = null; - dynamic? calculatedFields = null; - dynamic? field = null; - - try - { - pivot = FindPivotTable(ctx.Book, pivotTableName); - - // Check if this is an OLAP PivotTable - they don't support calculated fields - if (PivotTableHelpers.IsOlapPivotTable(pivot)) - { - return new OperationResult - { - Success = false, - ErrorMessage = $"PivotTable '{pivotTableName}' is an OLAP PivotTable. OLAP PivotTables do not support calculated fields. Use delete-calculated-member instead for Data Model PivotTables." - }; - } - - calculatedFields = pivot.CalculatedFields(); - - // Find the calculated field by name - bool found = false; - int count = calculatedFields.Count; - for (int i = 1; i <= count; i++) - { - dynamic? checkField = null; - try - { - checkField = calculatedFields.Item(i); - string name = checkField.Name?.ToString() ?? string.Empty; - if (name.Equals(fieldName, StringComparison.OrdinalIgnoreCase)) - { - field = checkField; - checkField = null; // Transfer ownership - found = true; - break; - } - } - finally - { - if (checkField != null) - { - ComUtilities.Release(ref checkField); - } - } - } - - if (!found) - { - return new OperationResult - { - Success = false, - ErrorMessage = $"Calculated field '{fieldName}' not found in PivotTable '{pivotTableName}'. Use list-calculated-fields to see available calculated fields." - }; - } - - // Delete the field - field.Delete(); - - // Refresh the PivotTable - pivot.RefreshTable(); - - return new OperationResult - { - Success = true - }; - } - finally - { - ComUtilities.Release(ref field); - ComUtilities.Release(ref calculatedFields); - ComUtilities.Release(ref pivot); - } - }); - } -} - - diff --git a/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.CalculatedMembers.cs b/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.CalculatedMembers.cs deleted file mode 100644 index 7d3fce1f..00000000 --- a/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.CalculatedMembers.cs +++ /dev/null @@ -1,246 +0,0 @@ -using System.Runtime.InteropServices; -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.PivotTable; - -/// <summary> -/// Calculated Members operations for PivotTableCommands. -/// Creates, lists, and deletes calculated members for OLAP PivotTables. -/// Note: Only works with OLAP (Data Model) PivotTables. Regular PivotTables use CalculatedFields. -/// </summary> -public partial class PivotTableCommands -{ - /// <inheritdoc/> - public CalculatedMemberListResult ListCalculatedMembers(IExcelBatch batch, string pivotTableName) - { - return batch.Execute((ctx, ct) => - { - dynamic? pivot = null; - dynamic? calculatedMembers = null; - - try - { - pivot = FindPivotTable(ctx.Book, pivotTableName); - - // Check if this is an OLAP PivotTable - if (!PivotTableHelpers.IsOlapPivotTable(pivot)) - { - return new CalculatedMemberListResult - { - Success = false, - ErrorMessage = $"PivotTable '{pivotTableName}' is not an OLAP PivotTable. Calculated members are only available for OLAP (Data Model) PivotTables. Use create-calculated-field for regular PivotTables." - }; - } - - calculatedMembers = pivot.CalculatedMembers; - var result = new CalculatedMemberListResult { Success = true }; - - for (int i = 1; i <= calculatedMembers.Count; i++) - { - dynamic? member = null; - try - { - member = calculatedMembers.Item(i); - var memberInfo = new CalculatedMemberInfo - { - Name = member.Name?.ToString() ?? string.Empty, - Formula = member.Formula?.ToString() ?? string.Empty, - Type = GetCalculatedMemberType(Convert.ToInt32(member.Type)), - SolveOrder = Convert.ToInt32(member.SolveOrder), - IsValid = member.IsValid - }; - - // Try to get optional properties (may not exist on all calculated member types) - try { memberInfo.DisplayFolder = member.DisplayFolder?.ToString(); } catch (System.Runtime.InteropServices.COMException) { /* Property not available */ } - try { memberInfo.NumberFormat = member.NumberFormat?.ToString(); } catch (System.Runtime.InteropServices.COMException) { /* Property not available */ } - - result.CalculatedMembers.Add(memberInfo); - } - finally - { - ComUtilities.Release(ref member); - } - } - - return result; - } - finally - { - ComUtilities.Release(ref calculatedMembers); - ComUtilities.Release(ref pivot); - } - }); - } - - /// <inheritdoc/> - public CalculatedMemberResult CreateCalculatedMember(IExcelBatch batch, string pivotTableName, - string memberName, string formula, CalculatedMemberType type = CalculatedMemberType.Measure, - int solveOrder = 0, string? displayFolder = null, string? numberFormat = null) - { - return batch.Execute((ctx, ct) => - { - dynamic? pivot = null; - dynamic? calculatedMembers = null; - dynamic? newMember = null; - - try - { - pivot = FindPivotTable(ctx.Book, pivotTableName); - - // Check if this is an OLAP PivotTable - if (!PivotTableHelpers.IsOlapPivotTable(pivot)) - { - return new CalculatedMemberResult - { - Success = false, - ErrorMessage = $"PivotTable '{pivotTableName}' is not an OLAP PivotTable. Calculated members are only available for OLAP (Data Model) PivotTables. Use create-calculated-field for regular PivotTables." - }; - } - - calculatedMembers = pivot.CalculatedMembers; - - // Convert type to COM constant - int comType = type switch - { - CalculatedMemberType.Member => XlCalculatedMemberType.xlCalculatedMember, - CalculatedMemberType.Set => XlCalculatedMemberType.xlCalculatedSet, - CalculatedMemberType.Measure => XlCalculatedMemberType.xlCalculatedMeasure, - _ => XlCalculatedMemberType.xlCalculatedMeasure - }; - - // Use AddCalculatedMember (Excel 2013+) for full feature support - // Parameters: Name, Formula, SolveOrder, Type, DisplayFolder, MeasureGroup, ParentHierarchy, ParentMember, NumberFormat - try - { - newMember = calculatedMembers.AddCalculatedMember( - memberName, - formula, - solveOrder, - comType, - displayFolder ?? Type.Missing, - Type.Missing, // MeasureGroup - auto-detect - Type.Missing, // ParentHierarchy - not needed for measures - Type.Missing, // ParentMember - not needed for measures - numberFormat ?? Type.Missing - ); - } - catch (System.Runtime.InteropServices.COMException comEx) - { - // MDX/DAX syntax errors or invalid formulas return specific COM errors - // Convert these to user-friendly error messages - string errorDetail = comEx.Message; - bool isFormulaError = errorDetail.Contains("Query") || - errorDetail.Contains("syntax") || - comEx.HResult == unchecked((int)0x800A03EC); - - if (isFormulaError) - { - return new CalculatedMemberResult - { - Success = false, - ErrorMessage = $"Invalid formula syntax for calculated {type}: {errorDetail}. Check MDX/DAX syntax and ensure referenced measures/dimensions exist." - }; - } - // Re-throw unknown COM errors - throw; - } - - var result = new CalculatedMemberResult - { - Success = true, - Name = newMember.Name?.ToString() ?? memberName, - Formula = newMember.Formula?.ToString() ?? formula, - Type = GetCalculatedMemberType(Convert.ToInt32(newMember.Type)), - SolveOrder = Convert.ToInt32(newMember.SolveOrder), - IsValid = newMember.IsValid, - WorkflowHint = $"Created calculated {type} '{memberName}'. Use add-value-field with fieldName='[Measures].[{memberName}]' to add it to the PivotTable values area." - }; - - // Try to get optional properties (may not exist on all calculated member types) - try { result.DisplayFolder = newMember.DisplayFolder?.ToString(); } catch (System.Runtime.InteropServices.COMException) { /* Property not available */ } - try { result.NumberFormat = newMember.NumberFormat?.ToString(); } catch (System.Runtime.InteropServices.COMException) { /* Property not available */ } - - return result; - } - finally - { - ComUtilities.Release(ref newMember); - ComUtilities.Release(ref calculatedMembers); - ComUtilities.Release(ref pivot); - } - }); - } - - /// <inheritdoc/> - public OperationResult DeleteCalculatedMember(IExcelBatch batch, string pivotTableName, string memberName) - { - return batch.Execute((ctx, ct) => - { - dynamic? pivot = null; - dynamic? calculatedMembers = null; - dynamic? member = null; - - try - { - pivot = FindPivotTable(ctx.Book, pivotTableName); - - // Check if this is an OLAP PivotTable - if (!PivotTableHelpers.IsOlapPivotTable(pivot)) - { - return new OperationResult - { - Success = false, - ErrorMessage = $"PivotTable '{pivotTableName}' is not an OLAP PivotTable. Calculated members are only available for OLAP (Data Model) PivotTables." - }; - } - - calculatedMembers = pivot.CalculatedMembers; - - // Find the member by name - try - { - member = calculatedMembers.Item(memberName); - } - catch (COMException) - { - return new OperationResult - { - Success = false, - ErrorMessage = $"Calculated member '{memberName}' not found in PivotTable '{pivotTableName}'. Use list-calculated-members to see available members." - }; - } - - member.Delete(); - - return new OperationResult - { - Success = true - }; - } - finally - { - ComUtilities.Release(ref member); - ComUtilities.Release(ref calculatedMembers); - ComUtilities.Release(ref pivot); - } - }); - } - - /// <summary> - /// Converts COM calculated member type constant to enum - /// </summary> - private static CalculatedMemberType GetCalculatedMemberType(int comType) - { - return comType switch - { - XlCalculatedMemberType.xlCalculatedMember => CalculatedMemberType.Member, - XlCalculatedMemberType.xlCalculatedSet => CalculatedMemberType.Set, - XlCalculatedMemberType.xlCalculatedMeasure => CalculatedMemberType.Measure, - _ => CalculatedMemberType.Member - }; - } -} - - diff --git a/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.Create.cs b/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.Create.cs deleted file mode 100644 index 1ce23e5f..00000000 --- a/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.Create.cs +++ /dev/null @@ -1,456 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.PivotTable; - -/// <summary> -/// PivotTable creation operations -/// </summary> -public partial class PivotTableCommands -{ - /// <summary> - /// Creates a PivotTable from an Excel range - /// Following VBA pattern from ReneNyffenegger/about-MS-Office-object-model - /// </summary> - public PivotTableCreateResult CreateFromRange(IExcelBatch batch, - string sourceSheet, string sourceRange, - string destinationSheet, string destinationCell, - string pivotTableName) - { - using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); - - return batch.Execute((ctx, ct) => - { - dynamic? sourceWorksheet = null; - dynamic? sourceRangeObj = null; - dynamic? destWorksheet = null; - dynamic? destRangeObj = null; - dynamic? pivotCaches = null; - dynamic? pivotCache = null; - dynamic? pivotTable = null; - - // STEP 1: Validate source range has headers and data - sourceWorksheet = ctx.Book.Worksheets[sourceSheet]; - sourceRangeObj = sourceWorksheet.Range[sourceRange]; - - if (sourceRangeObj.Rows.Count < 2) - { - throw new InvalidOperationException($"Source range must contain headers and at least one data row. Found {sourceRangeObj.Rows.Count} rows"); - } - - // STEP 2: Create PivotCache from source range - // VBA: Set pivot_cache = activeWorkbook.PivotCaches.Create(SourceType:=xlDatabase, SourceData:="csv_data", Version:=xlPivotTableVersion14) - pivotCaches = ctx.Book.PivotCaches(); - // Sheet names with spaces or special characters must be quoted: 'Sheet Name'!A1:D6 - string sourceDataRef = $"'{sourceSheet}'!{sourceRange}"; - - // xlDatabase = 1, xlPivotTableVersion14 = 4 - pivotCache = pivotCaches.Create( - SourceType: 1, - SourceData: sourceDataRef, - Version: 4 - ); - - // STEP 3: Create PivotTable from cache - // VBA: Set pivot_table = pivot_cache.CreatePivotTable(TableDestination:=pivot_table_upper_left) - destWorksheet = ctx.Book.Worksheets[destinationSheet]; - destRangeObj = destWorksheet.Range[destinationCell]; - - pivotTable = pivotCache.CreatePivotTable( - TableDestination: destRangeObj, - TableName: pivotTableName - ); - - // STEP 4: Refresh to materialize the PivotTable structure - pivotTable.RefreshTable(); - - // STEP 5: Get available fields from PivotTable (VBA pattern) - // VBA: Set pf_col_1 = pivot_table.PivotFields("col_1") - // STEP 5: Get available fields from source range headers - // These are the fields that CAN be added to the PivotTable - var availableFields = new List<string>(); - - dynamic? headerRow = null; - try - { - headerRow = sourceRangeObj.Rows[1]; - object[,] headers = headerRow.Value2; - - for (int col = 1; col <= headers.GetLength(1); col++) - { - var header = headers[1, col]?.ToString(); - if (!string.IsNullOrWhiteSpace(header)) - { - availableFields.Add(header); - } - } - - if (availableFields.Count == 0) - { - throw new InvalidOperationException($"No field headers found in source range. Header row has {headers.GetLength(1)} columns."); - } - } - finally - { - ComUtilities.Release(ref headerRow); - } - - try - { - return new PivotTableCreateResult - { - Success = true, - PivotTableName = pivotTableName, - SheetName = destinationSheet, - Range = pivotTable.TableRange2.Address, - SourceData = sourceDataRef, - SourceRowCount = sourceRangeObj.Rows.Count - 1, - AvailableFields = availableFields, - FilePath = batch.WorkbookPath - }; - } - finally - { - ComUtilities.Release(ref pivotTable); - ComUtilities.Release(ref pivotCache); - ComUtilities.Release(ref pivotCaches); - ComUtilities.Release(ref destRangeObj); - ComUtilities.Release(ref destWorksheet); - ComUtilities.Release(ref sourceRangeObj); - ComUtilities.Release(ref sourceWorksheet); - } - }, timeoutCts.Token); - } - - /// <summary> - /// Creates a PivotTable from an Excel Table - /// </summary> - public PivotTableCreateResult CreateFromTable(IExcelBatch batch, - string tableName, - string destinationSheet, string destinationCell, - string pivotTableName) - { - return batch.Execute((ctx, ct) => - { - dynamic? table = null; - dynamic? destWorksheet = null; - dynamic? destRangeObj = null; - dynamic? pivotCaches = null; - dynamic? pivotCache = null; - dynamic? pivotTable = null; - - // Find the table - dynamic? sheets = null; - bool tableFound = false; - - try - { - sheets = ctx.Book.Worksheets; - for (int i = 1; i <= sheets.Count; i++) - { - dynamic? sheet = null; - dynamic? listObjects = null; - try - { - sheet = sheets.Item(i); - listObjects = sheet.ListObjects; - - for (int j = 1; j <= listObjects.Count; j++) - { - dynamic? tbl = null; - try - { - tbl = listObjects.Item(j); - if (tbl.Name == tableName) - { - table = tbl; - tableFound = true; - break; - } - } - finally - { - if (tbl != null && tbl != table) - { - ComUtilities.Release(ref tbl); - } - } - } - - if (tableFound) break; - } - finally - { - ComUtilities.Release(ref listObjects); - ComUtilities.Release(ref sheet); - } - } - } - finally - { - ComUtilities.Release(ref sheets); - } - - if (!tableFound || table == null) - { - throw new InvalidOperationException($"Table '{tableName}' not found in workbook"); - } - - // Get table range and headers - dynamic? tableRange = null; - dynamic? headerRow = null; - var headers = new List<string>(); - int rowCount = 0; - - try - { - tableRange = table.Range; - rowCount = tableRange.Rows.Count; - - if (rowCount < 2) - { - throw new InvalidOperationException($"Table '{tableName}' must contain at least one data row (has {rowCount} rows including header)"); - } - - // Get headers - dynamic? headerRowCol = null; - try - { - headerRowCol = table.HeaderRowRange; - object[,] headerValues = headerRowCol.Value2; - - for (int col = 1; col <= headerValues.GetLength(1); col++) - { - var header = headerValues[1, col]?.ToString(); - if (!string.IsNullOrWhiteSpace(header)) - { - headers.Add(header); - } - } - } - finally - { - ComUtilities.Release(ref headerRowCol); - } - - // Create PivotCache from table - pivotCaches = ctx.Book.PivotCaches(); - string sourceDataRef = $"{table.Parent.Name}!{table.Name}"; - - // xlDatabase = 1 - pivotCache = pivotCaches.Create( - SourceType: 1, - SourceData: sourceDataRef - ); - - // Create PivotTable - destWorksheet = ctx.Book.Worksheets[destinationSheet]; - destRangeObj = destWorksheet.Range[destinationCell]; - - pivotTable = pivotCache.CreatePivotTable( - TableDestination: destRangeObj, - TableName: pivotTableName - ); - - // Refresh to materialize layout - pivotTable.RefreshTable(); - - try - { - return new PivotTableCreateResult - { - Success = true, - PivotTableName = pivotTableName, - SheetName = destinationSheet, - Range = pivotTable.TableRange2.Address, - SourceData = sourceDataRef, - SourceRowCount = rowCount - 1, // Exclude header - AvailableFields = headers, - FilePath = batch.WorkbookPath - }; - } - finally - { - ComUtilities.Release(ref pivotTable); - ComUtilities.Release(ref pivotCache); - ComUtilities.Release(ref pivotCaches); - ComUtilities.Release(ref destRangeObj); - ComUtilities.Release(ref destWorksheet); - ComUtilities.Release(ref table); - } - } - finally - { - ComUtilities.Release(ref headerRow); - ComUtilities.Release(ref tableRange); - } - }); - } - - /// <summary> - /// Creates a PivotTable from a Power Pivot Data Model table - /// Uses xlExternal source type with "ThisWorkbookDataModel" connection - /// </summary> - public PivotTableCreateResult CreateFromDataModel(IExcelBatch batch, - string tableName, - string destinationSheet, string destinationCell, - string pivotTableName) - { - return batch.Execute((ctx, ct) => - { - dynamic? model = null; - dynamic? modelTable = null; - dynamic? destWorksheet = null; - dynamic? destRangeObj = null; - dynamic? pivotCaches = null; - dynamic? pivotCache = null; - dynamic? pivotTable = null; - - // STEP 1: Verify Data Model exists and find the table - // NOTE: Every workbook has a Model object, but it may be empty - model = ctx.Book.Model; - - // Find the table in the Data Model - dynamic? modelTables = null; - bool tableFound = false; - try - { - modelTables = model.ModelTables; - - // Check if Data Model has any tables - if (modelTables == null || modelTables.Count == 0) - { - throw new InvalidOperationException("Data Model does not contain any tables"); - } - - for (int i = 1; i <= modelTables.Count; i++) - { - dynamic? tbl = null; - try - { - tbl = modelTables.Item(i); - if (tbl.Name == tableName) - { - modelTable = tbl; - tableFound = true; - break; - } - } - finally - { - if (tbl != null && tbl != modelTable) - { - ComUtilities.Release(ref tbl); - } - } - } - } - finally - { - ComUtilities.Release(ref modelTables); - } - - if (!tableFound || modelTable == null) - { - throw new InvalidOperationException($"Table '{tableName}' not found in Data Model"); - } - - // Get columns from the Data Model table - var headers = new List<string>(); - int recordCount = 0; - - try - { - recordCount = ComUtilities.SafeGetInt(modelTable, "RecordCount"); - - // Get columns - dynamic? modelColumns = null; - try - { - modelColumns = modelTable.ModelTableColumns; - for (int i = 1; i <= modelColumns.Count; i++) - { - dynamic? column = null; - try - { - column = modelColumns.Item(i); - var colName = ComUtilities.SafeGetString(column, "Name"); - if (!string.IsNullOrWhiteSpace(colName)) - { - headers.Add(colName); - } - } - finally - { - ComUtilities.Release(ref column); - } - } - } - finally - { - ComUtilities.Release(ref modelColumns); - } - } - catch (Exception ex) - { - throw new InvalidOperationException($"Failed to read columns from Data Model table '{tableName}': {ex.Message}"); - } - - if (headers.Count == 0) - { - throw new InvalidOperationException($"Data Model table '{tableName}' has no columns"); - } - - // STEP 2: Create PivotCache from Data Model - // Using xlExternal (2) with "ThisWorkbookDataModel" connection - pivotCaches = ctx.Book.PivotCaches(); - - // xlExternal = 2 - pivotCache = pivotCaches.Create( - SourceType: 2, - SourceData: "ThisWorkbookDataModel" - ); - - // STEP 3: Create PivotTable from cache - destWorksheet = ctx.Book.Worksheets[destinationSheet]; - destRangeObj = destWorksheet.Range[destinationCell]; - - pivotTable = pivotCache.CreatePivotTable( - TableDestination: destRangeObj, - TableName: pivotTableName - ); - - // STEP 4: Refresh to materialize the PivotTable structure - pivotTable.RefreshTable(); - - try - { - return new PivotTableCreateResult - { - Success = true, - PivotTableName = pivotTableName, - SheetName = destinationSheet, - Range = pivotTable.TableRange2.Address, - SourceData = $"ThisWorkbookDataModel[{tableName}]", - SourceRowCount = recordCount, - AvailableFields = headers, - FilePath = batch.WorkbookPath - }; - } - finally - { - ComUtilities.Release(ref pivotTable); - ComUtilities.Release(ref pivotCache); - ComUtilities.Release(ref pivotCaches); - ComUtilities.Release(ref destRangeObj); - ComUtilities.Release(ref destWorksheet); - ComUtilities.Release(ref modelTable); - ComUtilities.Release(ref model); - } - }); - } -} - - - diff --git a/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.Fields.cs b/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.Fields.cs deleted file mode 100644 index f838faf4..00000000 --- a/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.Fields.cs +++ /dev/null @@ -1,332 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.PivotTable; - -/// <summary> -/// PivotTable field management operations -/// </summary> -public partial class PivotTableCommands -{ - /// <summary> - /// Lists all fields in a PivotTable - /// </summary> - public PivotFieldListResult ListFields(IExcelBatch batch, string pivotTableName) - { - return batch.Execute((ctx, ct) => - { - dynamic? pivot = null; - dynamic? pivotFields = null; - dynamic? cubeFields = null; - - pivot = FindPivotTable(ctx.Book, pivotTableName); - - // Check if this is an OLAP/Data Model PivotTable - bool isOlap = PivotTableHelpers.TryGetCubeFields(pivot, out cubeFields); - - try - { - // For OLAP PivotTables, use CubeFields instead of PivotFields - if (isOlap) - { - return ListCubeFieldsAsync(cubeFields, batch.WorkbookPath); - } - else - { - // Regular PivotTable - use PivotFields - pivotFields = pivot.PivotFields; - return ListRegularFieldsAsync(pivotFields, batch.WorkbookPath); - } - } - finally - { - ComUtilities.Release(ref cubeFields); - ComUtilities.Release(ref pivotFields); - ComUtilities.Release(ref pivot); - } - }); - } - - /// <summary> - /// Lists fields from OLAP/Data Model PivotTable using CubeFields - /// </summary> - private static PivotFieldListResult ListCubeFieldsAsync(dynamic cubeFields, string filePath) - { - var fields = new List<PivotFieldInfo>(); - - int fieldCount = cubeFields.Count; - - for (int i = 1; i <= fieldCount; i++) - { - dynamic? cubeField = null; - try - { - cubeField = cubeFields.Item(i); - - // Get field name - string fieldName; - try - { - fieldName = cubeField.Name?.ToString() ?? $"CubeField{i}"; - } - catch (System.Runtime.InteropServices.COMException) - { - fieldName = $"CubeField{i}"; - } - - // Get orientation - for CubeFields, check if it has a corresponding PivotField - int orientation = XlPivotFieldOrientation.xlHidden; - try - { - dynamic? pivotField = cubeField.PivotFields?.Item(1); - if (pivotField != null) - { - orientation = Convert.ToInt32(pivotField.Orientation); - ComUtilities.Release(ref pivotField); - } - } - catch (System.Runtime.InteropServices.COMException) - { - orientation = XlPivotFieldOrientation.xlHidden; - } - - var fieldInfo = new PivotFieldInfo - { - Name = fieldName, - Area = orientation switch - { - XlPivotFieldOrientation.xlRowField => PivotFieldArea.Row, - XlPivotFieldOrientation.xlColumnField => PivotFieldArea.Column, - XlPivotFieldOrientation.xlPageField => PivotFieldArea.Filter, - XlPivotFieldOrientation.xlDataField => PivotFieldArea.Value, - _ => PivotFieldArea.Hidden - }, - CustomName = string.Empty, - Position = 0, - DataType = "Cube" - }; - - fields.Add(fieldInfo); - } - catch (System.Runtime.InteropServices.COMException) - { - // Skip field if COM access fails - continue with other fields - } - finally - { - ComUtilities.Release(ref cubeField); - } - } - - return new PivotFieldListResult - { - Success = true, - Fields = fields, - FilePath = filePath - }; - } - - /// <summary> - /// Lists fields from regular PivotTable using PivotFields - /// </summary> - private static PivotFieldListResult ListRegularFieldsAsync(dynamic pivotFields, string filePath) - { - var fields = new List<PivotFieldInfo>(); - - int fieldCount = pivotFields.Count; - - for (int i = 1; i <= fieldCount; i++) - { - dynamic? field = null; - try - { - field = pivotFields.Item(i); - - // Get field name with defensive handling (can throw on Data Model fields) - string fieldName; - try - { - fieldName = field.SourceName?.ToString() ?? field.Name?.ToString() ?? $"Field{i}"; - } - catch (System.Runtime.InteropServices.COMException) - { - fieldName = $"Field{i}"; - } - - // Get orientation with defensive handling - int orientation; - try - { - orientation = Convert.ToInt32(field.Orientation); - } - catch (System.Runtime.InteropServices.COMException) - { - orientation = XlPivotFieldOrientation.xlHidden; - } - - var fieldInfo = new PivotFieldInfo - { - Name = fieldName, - Area = orientation switch - { - XlPivotFieldOrientation.xlRowField => PivotFieldArea.Row, - XlPivotFieldOrientation.xlColumnField => PivotFieldArea.Column, - XlPivotFieldOrientation.xlPageField => PivotFieldArea.Filter, - XlPivotFieldOrientation.xlDataField => PivotFieldArea.Value, - _ => PivotFieldArea.Hidden - } - }; - - // CustomName - defensive - try - { - fieldInfo.CustomName = field.Caption?.ToString() ?? string.Empty; - } - catch (System.Runtime.InteropServices.COMException) - { - fieldInfo.CustomName = string.Empty; - } - - // Position - defensive - try - { - fieldInfo.Position = orientation != XlPivotFieldOrientation.xlHidden ? Convert.ToInt32(field.Position) : 0; - } - catch (System.Runtime.InteropServices.COMException) - { - fieldInfo.Position = 0; - } - - // DataType - defensive - try - { - fieldInfo.DataType = PivotTableHelpers.DetectFieldDataType(field); - } - catch (System.Runtime.InteropServices.COMException) - { - fieldInfo.DataType = "Unknown"; - } - - // Get function for value fields - defensive - if (orientation == XlPivotFieldOrientation.xlDataField) - { - try - { - int comFunction = Convert.ToInt32(field.Function); - fieldInfo.Function = PivotTableHelpers.GetAggregationFunctionFromCom(comFunction); - } - catch (System.Runtime.InteropServices.COMException) - { - fieldInfo.Function = AggregationFunction.Sum; // Default - } - } - - fields.Add(fieldInfo); - } - catch (System.Runtime.InteropServices.COMException) - { - // Skip field if COM access fails - continue with other fields - } - finally - { - ComUtilities.Release(ref field); - } - } - - return new PivotFieldListResult - { - Success = true, - Fields = fields, - FilePath = filePath - }; - } - - /// <summary> - /// Adds a field to the Row area - /// </summary> - public PivotFieldResult AddRowField(IExcelBatch batch, string pivotTableName, - string fieldName, int? position = null) - => ExecuteWithStrategy<PivotFieldResult>(batch, pivotTableName, - (strategy, pivot) => strategy.AddRowField(pivot, fieldName, position, batch.WorkbookPath)); - - /// <summary> - /// Adds a field to the Column area - /// </summary> - public PivotFieldResult AddColumnField(IExcelBatch batch, string pivotTableName, - string fieldName, int? position = null) - => ExecuteWithStrategy<PivotFieldResult>(batch, pivotTableName, - (strategy, pivot) => strategy.AddColumnField(pivot, fieldName, position, batch.WorkbookPath)); - - /// <summary> - /// Adds a field to the Values area with aggregation - /// </summary> - public PivotFieldResult AddValueField(IExcelBatch batch, string pivotTableName, - string fieldName, AggregationFunction aggregationFunction = AggregationFunction.Sum, - string? customName = null) - => ExecuteWithStrategy<PivotFieldResult>(batch, pivotTableName, - (strategy, pivot) => strategy.AddValueField(pivot, fieldName, aggregationFunction, customName, batch.WorkbookPath)); - - /// <summary> - /// Adds a field to the Filter area - /// </summary> - public PivotFieldResult AddFilterField(IExcelBatch batch, string pivotTableName, - string fieldName) - => ExecuteWithStrategy<PivotFieldResult>(batch, pivotTableName, - (strategy, pivot) => strategy.AddFilterField(pivot, fieldName, batch.WorkbookPath)); - - /// <summary> - /// Removes a field from any area - /// </summary> - public PivotFieldResult RemoveField(IExcelBatch batch, string pivotTableName, - string fieldName) - => ExecuteWithStrategy<PivotFieldResult>(batch, pivotTableName, - (strategy, pivot) => strategy.RemoveField(pivot, fieldName, batch.WorkbookPath)); - - /// <summary> - /// Sets the aggregation function for a value field - /// </summary> - public PivotFieldResult SetFieldFunction(IExcelBatch batch, string pivotTableName, - string fieldName, AggregationFunction aggregationFunction) - => ExecuteWithStrategy<PivotFieldResult>(batch, pivotTableName, - (strategy, pivot) => strategy.SetFieldFunction(pivot, fieldName, aggregationFunction, batch.WorkbookPath)); - - /// <summary> - /// Sets custom name for a field - /// </summary> - public PivotFieldResult SetFieldName(IExcelBatch batch, string pivotTableName, - string fieldName, string customName) - => ExecuteWithStrategy<PivotFieldResult>(batch, pivotTableName, - (strategy, pivot) => strategy.SetFieldName(pivot, fieldName, customName, batch.WorkbookPath)); - - /// <summary> - /// Sets number format for a value field - /// </summary> - public PivotFieldResult SetFieldFormat(IExcelBatch batch, string pivotTableName, - string fieldName, string numberFormat) - { - return batch.Execute((ctx, ct) => - { - dynamic? pivot = null; - - pivot = FindPivotTable(ctx.Book, pivotTableName); - - try - { - // Translate US format codes to locale-specific codes - var translatedFormat = ctx.FormatTranslator.TranslateToLocale(numberFormat); - - // Use Strategy Pattern to delegate to appropriate implementation - var strategy = PivotTableFieldStrategyFactory.GetStrategy(pivot); - return strategy.SetFieldFormat(pivot, fieldName, translatedFormat, batch.WorkbookPath); - } - finally - { - ComUtilities.Release(ref pivot); - } - }); - } -} - - - diff --git a/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.GrandTotals.cs b/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.GrandTotals.cs deleted file mode 100644 index 03f26280..00000000 --- a/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.GrandTotals.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.PivotTable; - -/// <summary> -/// PivotTable grand totals commands - show/hide row and column grand totals. -/// </summary> -public partial class PivotTableCommands -{ - /// <summary> - /// Shows or hides grand totals for rows and/or columns in the PivotTable. - /// </summary> - /// <param name="batch">The Excel batch session containing the workbook.</param> - /// <param name="pivotTableName">Name of the PivotTable to configure.</param> - /// <param name="showRowGrandTotals">Show row grand totals (bottom summary row).</param> - /// <param name="showColumnGrandTotals">Show column grand totals (right summary column).</param> - /// <returns>Operation result indicating success or failure.</returns> - /// <remarks> - /// GRAND TOTALS: - /// - Row Grand Totals: Summary row displayed at the bottom of the PivotTable - /// - Column Grand Totals: Summary column displayed at the right of the PivotTable - /// - Independent control: Can show/hide row and column totals separately - /// - /// SUPPORT: - /// - Regular PivotTables: Full support - /// - OLAP PivotTables: Full support (same COM properties) - /// </remarks> - public OperationResult SetGrandTotals(IExcelBatch batch, string pivotTableName, bool showRowGrandTotals, bool showColumnGrandTotals) - => ExecuteWithStrategy<OperationResult>(batch, pivotTableName, - (strategy, pivot) => strategy.SetGrandTotals(pivot, showRowGrandTotals, showColumnGrandTotals, batch.WorkbookPath, batch.Logger)); -} - - diff --git a/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.Grouping.cs b/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.Grouping.cs deleted file mode 100644 index b4210713..00000000 --- a/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.Grouping.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.PivotTable; - -/// <summary> -/// PivotTable grouping operations (GroupByDate, GroupByNumeric) -/// </summary> -public partial class PivotTableCommands -{ - /// <summary> - /// Groups a date/time field by the specified interval (Month, Quarter, Year) - /// </summary> - public PivotFieldResult GroupByDate(IExcelBatch batch, string pivotTableName, - string fieldName, DateGroupingInterval interval) - => ExecuteWithStrategy<PivotFieldResult>(batch, pivotTableName, - (strategy, pivot) => strategy.GroupByDate(pivot, fieldName, interval, batch.WorkbookPath, batch.Logger)); - - /// <summary> - /// Groups a numeric field by the specified interval (e.g., 0-100, 100-200, 200-300) - /// </summary> - public PivotFieldResult GroupByNumeric(IExcelBatch batch, string pivotTableName, - string fieldName, double? start, double? endValue, double intervalSize) - => ExecuteWithStrategy<PivotFieldResult>(batch, pivotTableName, - (strategy, pivot) => strategy.GroupByNumeric(pivot, fieldName, start, endValue, intervalSize, batch.WorkbookPath, batch.Logger)); -} - - diff --git a/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.Layout.cs b/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.Layout.cs deleted file mode 100644 index d41cb8ee..00000000 --- a/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.Layout.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.PivotTable; - -/// <summary> -/// PivotTable layout operations - compact, outline, and tabular forms. -/// </summary> -public partial class PivotTableCommands -{ - /// <summary> - /// Sets the row layout form for a PivotTable. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Name of the PivotTable</param> - /// <param name="rowLayout">Layout form: 0=Compact, 1=Tabular, 2=Outline</param> - /// <returns>Result indicating success or failure</returns> - /// <remarks> - /// LAYOUT FORMS: - /// - Compact (0): All row fields in single column with indentation (Excel default) - /// - Tabular (1): Each field in separate column, subtotals at bottom - /// - Outline (2): Each field in separate column, subtotals at top - /// - /// SUPPORT: - /// - Regular PivotTables: Full support for all three forms - /// - OLAP PivotTables: Full support for all three forms - /// </remarks> - public OperationResult SetLayout(IExcelBatch batch, string pivotTableName, int rowLayout) - => ExecuteWithStrategy<OperationResult>(batch, pivotTableName, - (strategy, pivot) => strategy.SetLayout(pivot, rowLayout, batch.WorkbookPath, batch.Logger)); -} - - diff --git a/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.Lifecycle.cs b/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.Lifecycle.cs deleted file mode 100644 index 5b6ff3bf..00000000 --- a/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.Lifecycle.cs +++ /dev/null @@ -1,517 +0,0 @@ -using System.Runtime.InteropServices; -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.PivotTable; - -/// <summary> -/// PivotTable lifecycle operations (List, GetInfo, Create, Delete, Refresh) -/// </summary> -public partial class PivotTableCommands -{ - /// <summary> - /// Lists all PivotTables in workbook - /// </summary> - public PivotTableListResult List(IExcelBatch batch) - { - return batch.Execute((ctx, ct) => - { - var pivotTables = new List<PivotTableInfo>(); - dynamic? sheets = null; - - try - { - sheets = ctx.Book.Worksheets; - for (int i = 1; i <= sheets.Count; i++) - { - dynamic? sheet = null; - dynamic? pivotTablesCol = null; - try - { - sheet = sheets.Item(i); - string sheetName = sheet.Name; - pivotTablesCol = sheet.PivotTables; - - for (int j = 1; j <= pivotTablesCol.Count; j++) - { - dynamic? pivot = null; - dynamic? pivotCache = null; - try - { - pivot = pivotTablesCol.Item(j); - - // Get basic info (should never fail) - string pivotName = pivot.Name; - - // Get properties with defensive error handling - string? range = null; - string? sourceData = null; - int rowFieldCount = 0; - int columnFieldCount = 0; - int valueFieldCount = 0; - int filterFieldCount = 0; - DateTime? lastRefresh = null; - - try - { - range = pivot.TableRange2.Address; - } - catch (System.Runtime.InteropServices.COMException) - { - // TableRange2 might fail for disconnected PivotTables - range = "(unavailable)"; - } - - try - { - pivotCache = pivot.PivotCache; - sourceData = pivotCache.SourceData?.ToString() ?? string.Empty; - - // Handle RefreshDate which can be DateTime or double (OLE date) - if (pivotCache.RefreshDate != null) - { - var refreshDate = pivotCache.RefreshDate; - if (refreshDate is DateTime dt) - { - lastRefresh = dt; - } - else if (refreshDate is double dbl) - { - lastRefresh = DateTime.FromOADate(dbl); - } - } - } - catch (System.Runtime.InteropServices.COMException) - { - // SourceData might fail for Data Model or external sources - sourceData = "(external or Data Model)"; - } - - try - { - rowFieldCount = pivot.RowFields.Count; - } - catch (COMException) - { - // RowFields.Count may fail for Data Model or OLAP PivotTables - } - - try - { - columnFieldCount = pivot.ColumnFields.Count; - } - catch (COMException) - { - // ColumnFields.Count may fail for Data Model or OLAP PivotTables - } - - try - { - valueFieldCount = pivot.DataFields.Count; - } - catch (COMException) - { - // DataFields.Count may fail for Data Model or OLAP PivotTables - } - - try - { - filterFieldCount = pivot.PageFields.Count; - } - catch (COMException) - { - // PageFields.Count may fail for Data Model or OLAP PivotTables - } - - var info = new PivotTableInfo - { - Name = pivotName, - SheetName = sheetName, - Range = range ?? "(unavailable)", - SourceData = sourceData ?? string.Empty, - RowFieldCount = rowFieldCount, - ColumnFieldCount = columnFieldCount, - ValueFieldCount = valueFieldCount, - FilterFieldCount = filterFieldCount, - LastRefresh = lastRefresh - }; - - pivotTables.Add(info); - } - catch (Exception ex) - { - // Log but don't fail entire list operation for one bad PivotTable - // Continue to next PivotTable - System.Diagnostics.Debug.WriteLine($"Skipping PivotTable {j} on sheet {sheetName}: {ex.Message}"); - } - finally - { - ComUtilities.Release(ref pivotCache); - ComUtilities.Release(ref pivot); - } - } - } - finally - { - ComUtilities.Release(ref pivotTablesCol); - ComUtilities.Release(ref sheet); - } - } - - return new PivotTableListResult - { - Success = true, - PivotTables = pivotTables, - FilePath = batch.WorkbookPath - }; - } - finally - { - ComUtilities.Release(ref sheets); - } - }); - } - - /// <summary> - /// Gets detailed information about a PivotTable - /// </summary> - public PivotTableInfoResult Read(IExcelBatch batch, string pivotTableName) - { - return batch.Execute((ctx, ct) => - { - dynamic? pivot = null; - dynamic? pivotCache = null; - dynamic? cubeFields = null; - dynamic? pivotFields = null; - - pivot = FindPivotTable(ctx.Book, pivotTableName); - pivotCache = pivot.PivotCache; - - // Get basic info with defensive error handling (properties can throw on Data Model sources) - var info = new PivotTableInfo - { - Name = pivot.Name, - SheetName = pivot.Parent.Name - }; - - // TableRange2 - can throw on Data Model sources - try - { - info.Range = pivot.TableRange2.Address; - } - catch (COMException ex) when (ex.HResult == unchecked((int)0x800A03EC)) - { - info.Range = "[Data Model - Range not available]"; - } - - // SourceData - can throw on Data Model sources - try - { - info.SourceData = pivotCache.SourceData?.ToString() ?? string.Empty; - } - catch (COMException ex) when (ex.HResult == unchecked((int)0x800A03EC)) - { - info.SourceData = "[Data Model Source]"; - } - - // Field counts - usually safe but wrap defensively - try - { - info.RowFieldCount = pivot.RowFields.Count; - info.ColumnFieldCount = pivot.ColumnFields.Count; - info.ValueFieldCount = pivot.DataFields.Count; - info.FilterFieldCount = pivot.PageFields.Count; - } - catch (System.Runtime.InteropServices.COMException) - { - // Field counts default to 0 if unavailable - } - - // RefreshDate - try - { - info.LastRefresh = GetRefreshDateSafe(pivotCache.RefreshDate); - } - catch (System.Runtime.InteropServices.COMException) - { - info.LastRefresh = null; - } - - // Get field details - use OLAP detection - List<PivotFieldInfo> fields; - bool isOlap = PivotTableHelpers.TryGetCubeFields(pivot, out cubeFields); - - try - { - if (isOlap) - { - // OLAP/Data Model PivotTable - use CubeFields - fields = GetCubeFieldsInfo(cubeFields); - } - else - { - // Regular PivotTable - use PivotFields - pivotFields = pivot.PivotFields; - fields = GetRegularFieldsInfo(pivotFields); - } - - return new PivotTableInfoResult - { - Success = true, - PivotTable = info, - Fields = fields, - FilePath = batch.WorkbookPath - }; - } - finally - { - ComUtilities.Release(ref cubeFields); - ComUtilities.Release(ref pivotFields); - ComUtilities.Release(ref pivotCache); - ComUtilities.Release(ref pivot); - } - }); - } - - /// <summary> - /// Gets field info from CubeFields (OLAP/Data Model PivotTables) - /// </summary> - private static List<PivotFieldInfo> GetCubeFieldsInfo(dynamic cubeFields) - { - var fields = new List<PivotFieldInfo>(); - - try - { - int fieldCount = cubeFields.Count; - - for (int i = 1; i <= fieldCount; i++) - { - dynamic? cubeField = null; - try - { - cubeField = cubeFields.Item(i); - - string fieldName; - try - { - fieldName = cubeField.Name?.ToString() ?? $"CubeField{i}"; - } - catch (System.Runtime.InteropServices.COMException) - { - // Name property access failed - use fallback - fieldName = $"CubeField{i}"; - } - - // Get orientation from PivotField if it exists - int orientation = XlPivotFieldOrientation.xlHidden; - try - { - dynamic? pivotField = cubeField.PivotFields?.Item(1); - if (pivotField != null) - { - orientation = Convert.ToInt32(pivotField.Orientation); - ComUtilities.Release(ref pivotField); - } - } - catch (System.Runtime.InteropServices.COMException) - { - // PivotField access failed - field may not be placed, use Hidden - orientation = XlPivotFieldOrientation.xlHidden; - } - - var fieldInfo = new PivotFieldInfo - { - Name = fieldName, - Area = orientation switch - { - XlPivotFieldOrientation.xlRowField => PivotFieldArea.Row, - XlPivotFieldOrientation.xlColumnField => PivotFieldArea.Column, - XlPivotFieldOrientation.xlPageField => PivotFieldArea.Filter, - XlPivotFieldOrientation.xlDataField => PivotFieldArea.Value, - _ => PivotFieldArea.Hidden - }, - CustomName = string.Empty, - Position = 0, - DataType = "Cube" - }; - - fields.Add(fieldInfo); - } - catch (System.Runtime.InteropServices.COMException) - { - // Individual field access failed - skip this field and continue - // This can happen with certain OLAP cube field types - } - finally - { - ComUtilities.Release(ref cubeField); - } - } - } - catch (System.Runtime.InteropServices.COMException) - { - // CubeFields collection access failed - return partial results - } - - return fields; - } - - /// <summary> - /// Gets field info from PivotFields (regular PivotTables) - /// </summary> - private static List<PivotFieldInfo> GetRegularFieldsInfo(dynamic pivotFields) - { - var fields = new List<PivotFieldInfo>(); - - try - { - int fieldCount = pivotFields.Count; - - for (int i = 1; i <= fieldCount; i++) - { - dynamic? field = null; - try - { - field = pivotFields.Item(i); - int orientation = Convert.ToInt32(field.Orientation); - - var fieldInfo = new PivotFieldInfo - { - Name = field.SourceName?.ToString() ?? field.Name?.ToString() ?? $"Field{i}", - CustomName = field.Caption?.ToString() ?? string.Empty, - Area = orientation switch - { - XlPivotFieldOrientation.xlRowField => PivotFieldArea.Row, - XlPivotFieldOrientation.xlColumnField => PivotFieldArea.Column, - XlPivotFieldOrientation.xlPageField => PivotFieldArea.Filter, - XlPivotFieldOrientation.xlDataField => PivotFieldArea.Value, - _ => PivotFieldArea.Hidden - }, - Position = orientation != XlPivotFieldOrientation.xlHidden ? Convert.ToInt32(field.Position) : 0, - DataType = PivotTableHelpers.DetectFieldDataType(field) - }; - - // Get function for value fields - if (orientation == XlPivotFieldOrientation.xlDataField) - { - int comFunction = Convert.ToInt32(field.Function); - fieldInfo.Function = PivotTableHelpers.GetAggregationFunctionFromCom(comFunction); - } - - fields.Add(fieldInfo); - } - catch (System.Runtime.InteropServices.COMException) - { - // Individual field access failed - skip this field and continue - // This can happen with calculated fields or special field types - } - finally - { - ComUtilities.Release(ref field); - } - } - } - catch (System.Runtime.InteropServices.COMException) - { - // PivotFields collection access failed - return partial results - } - - return fields; - } - - /// <summary> - /// Deletes a PivotTable - /// </summary> - public OperationResult Delete(IExcelBatch batch, string pivotTableName) - { - return batch.Execute((ctx, ct) => - { - dynamic? pivot = null; - dynamic? tableRange = null; - - try - { - pivot = FindPivotTable(ctx.Book, pivotTableName); - tableRange = pivot.TableRange2; - - // Delete the PivotTable - tableRange.Clear(); - - return new OperationResult - { - Success = true, - Action = "delete", - FilePath = batch.WorkbookPath - }; - } - finally - { - ComUtilities.Release(ref tableRange); - ComUtilities.Release(ref pivot); - } - }); - } - - /// <summary> - /// Refreshes a PivotTable - /// </summary> - public PivotTableRefreshResult Refresh(IExcelBatch batch, string pivotTableName, TimeSpan? timeout = null) - { - return batch.Execute((ctx, ct) => - { - dynamic? pivot = null; - dynamic? pivotCache = null; - - try - { - pivot = FindPivotTable(ctx.Book, pivotTableName); - pivotCache = pivot.PivotCache; - - int previousRecordCount = pivotCache.RecordCount; - - // Refresh the PivotTable - pivot.RefreshTable(); - - int currentRecordCount = pivotCache.RecordCount; - - return new PivotTableRefreshResult - { - Success = true, - PivotTableName = pivotTableName, - RefreshTime = DateTime.Now, - SourceRecordCount = currentRecordCount, - PreviousRecordCount = previousRecordCount, - StructureChanged = currentRecordCount != previousRecordCount, - FilePath = batch.WorkbookPath - }; - } - finally - { - ComUtilities.Release(ref pivotCache); - ComUtilities.Release(ref pivot); - } - }); - } - - /// <summary> - /// Safely converts Excel RefreshDate (which can be DateTime or double OLE date) to DateTime? - /// </summary> - private static DateTime? GetRefreshDateSafe(dynamic refreshDate) - { - if (refreshDate == null) - return null; - - if (refreshDate is DateTime dt) - return dt; - - if (refreshDate is double dbl) - return DateTime.FromOADate(dbl); - - return null; - } -} - - - - diff --git a/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.Slicers.cs b/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.Slicers.cs deleted file mode 100644 index 5a4ecd6c..00000000 --- a/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.Slicers.cs +++ /dev/null @@ -1,708 +0,0 @@ -using System.Runtime.InteropServices; - -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.PivotTable; - -/// <summary> -/// PivotTable slicer operations (CreateSlicer, ListSlicers, SetSlicerSelection, DeleteSlicer) -/// </summary> -public partial class PivotTableCommands -{ - /// <summary> - /// Creates a slicer for a PivotTable field - /// </summary> - public SlicerResult CreateSlicer(IExcelBatch batch, string pivotTableName, - string fieldName, string slicerName, string destinationSheet, string position) - { - return batch.Execute((ctx, ct) => - { - dynamic? pivot = null; - dynamic? slicerCaches = null; - dynamic? slicerCache = null; - dynamic? slicers = null; - dynamic? slicer = null; - dynamic? destSheet = null; - dynamic? destRange = null; - - try - { - pivot = FindPivotTable(ctx.Book, pivotTableName); - slicerCaches = ctx.Book.SlicerCaches; - - // Check if a SlicerCache already exists for this field+PivotTable - // If so, we add a new visual Slicer to the existing cache - slicerCache = FindExistingSlicerCache(slicerCaches, pivot, fieldName); - - if (slicerCache == null) - { - // Create new SlicerCache for this field - // SlicerCaches.Add(source, sourceField, name, slicerCacheType) - // source = PivotTable object - // sourceField = field name string - // name = cache name (optional, auto-generated if not provided) - - // For regular PivotTables, use field name directly - // For OLAP, may need the hierarchical name - slicerCache = slicerCaches.Add2(pivot, fieldName); - } - - // Get destination sheet and calculate position from cell reference - destSheet = ctx.Book.Worksheets[destinationSheet]; - destRange = destSheet.Range[position]; - - // Get position in points from the cell reference - double top = Convert.ToDouble(destRange.Top); - double left = Convert.ToDouble(destRange.Left); - - // Add visual Slicer to the cache - // Slicers.Add(SlicerDestination, Level, Name, Caption, Top, Left, Width, Height) - // For non-OLAP sources, Level should be Type.Missing or omitted - slicers = slicerCache.Slicers; - slicer = slicers.Add(destSheet, Type.Missing, slicerName, slicerName, top, left); - - // Build result - var result = BuildSlicerResult(slicer, slicerCache, fieldName); - result.Success = true; - result.WorkflowHint = $"Slicer '{slicerName}' created for field '{fieldName}'. Use SetSlicerSelection to filter data, or connect additional PivotTables to this slicer."; - - return result; - } - finally - { - ComUtilities.Release(ref destRange); - ComUtilities.Release(ref destSheet); - ComUtilities.Release(ref slicer); - ComUtilities.Release(ref slicers); - ComUtilities.Release(ref slicerCache); - ComUtilities.Release(ref slicerCaches); - ComUtilities.Release(ref pivot); - } - }); - } - - /// <summary> - /// Lists all slicers in the workbook, optionally filtered by PivotTable - /// </summary> - public SlicerListResult ListSlicers(IExcelBatch batch, string? pivotTableName = null) - { - return batch.Execute((ctx, ct) => - { - var result = new SlicerListResult { Success = true }; - dynamic? slicerCaches = null; - dynamic? targetPivot = null; - - try - { - slicerCaches = ctx.Book.SlicerCaches; - - // If filtering by PivotTable, find it first - if (!string.IsNullOrEmpty(pivotTableName)) - { - targetPivot = FindPivotTable(ctx.Book, pivotTableName); - } - - for (int cacheIndex = 1; cacheIndex <= slicerCaches.Count; cacheIndex++) - { - dynamic? cache = null; - dynamic? slicers = null; - - try - { - cache = slicerCaches.Item(cacheIndex); - - // If filtering by PivotTable, check if this cache is connected - if (targetPivot != null && !IsSlicerCacheConnectedToPivot(cache, targetPivot)) - { - continue; - } - - slicers = cache.Slicers; - for (int slicerIndex = 1; slicerIndex <= slicers.Count; slicerIndex++) - { - dynamic? slicer = null; - try - { - slicer = slicers.Item(slicerIndex); - var slicerInfo = BuildSlicerInfo(slicer, cache); - result.Slicers.Add(slicerInfo); - } - finally - { - ComUtilities.Release(ref slicer); - } - } - } - finally - { - ComUtilities.Release(ref slicers); - ComUtilities.Release(ref cache); - } - } - - return result; - } - finally - { - ComUtilities.Release(ref targetPivot); - ComUtilities.Release(ref slicerCaches); - } - }); - } - - /// <summary> - /// Sets the selection for a slicer - /// </summary> - public SlicerResult SetSlicerSelection(IExcelBatch batch, string slicerName, - List<string> selectedItems, bool clearFirst = true) - { - return batch.Execute((ctx, ct) => - { - dynamic? slicerCaches = null; - dynamic? targetCache = null; - dynamic? targetSlicer = null; - dynamic? slicerItems = null; - - try - { - slicerCaches = ctx.Book.SlicerCaches; - - // Find the slicer by name - var searchResult = FindSlicerByName(slicerCaches, slicerName); - targetCache = searchResult.Cache; - targetSlicer = searchResult.Slicer; - - if (targetSlicer == null || targetCache == null) - { - return new SlicerResult - { - Success = false, - ErrorMessage = $"Slicer '{slicerName}' not found in workbook" - }; - } - - // Get slicer items from the cache - slicerItems = targetCache.SlicerItems; - - // Build set of items to select for fast lookup - var itemsToSelect = new HashSet<string>(selectedItems, StringComparer.OrdinalIgnoreCase); - - // If no items specified, select all (clear filter) - bool selectAll = selectedItems.Count == 0; - - // Iterate through slicer items and set selection - for (int i = 1; i <= slicerItems.Count; i++) - { - dynamic? item = null; - try - { - item = slicerItems.Item(i); - string itemName = item.Name?.ToString() ?? string.Empty; - - if (selectAll) - { - item.Selected = true; - } - else if (clearFirst) - { - // Clear first mode: select only specified items - item.Selected = itemsToSelect.Contains(itemName); - } - else - { - // Additive mode: add to existing selection - if (itemsToSelect.Contains(itemName)) - { - item.Selected = true; - } - } - } - finally - { - ComUtilities.Release(ref item); - } - } - - // Build result with updated state - string fieldName = GetSlicerCacheFieldName(targetCache); - var result = BuildSlicerResult(targetSlicer, targetCache, fieldName); - result.Success = true; - result.WorkflowHint = selectAll - ? $"Slicer '{slicerName}' filter cleared - all items are now visible." - : $"Slicer '{slicerName}' selection updated to {selectedItems.Count} item(s)."; - - return result; - } - finally - { - ComUtilities.Release(ref slicerItems); - ComUtilities.Release(ref targetSlicer); - ComUtilities.Release(ref targetCache); - ComUtilities.Release(ref slicerCaches); - } - }); - } - - /// <summary> - /// Deletes a slicer from the workbook - /// </summary> - public OperationResult DeleteSlicer(IExcelBatch batch, string slicerName) - { - return batch.Execute((ctx, ct) => - { - dynamic? slicerCaches = null; - dynamic? targetCache = null; - dynamic? targetSlicer = null; - - try - { - slicerCaches = ctx.Book.SlicerCaches; - - // Find the slicer by name - var searchResult = FindSlicerByName(slicerCaches, slicerName); - targetCache = searchResult.Cache; - targetSlicer = searchResult.Slicer; - - if (targetSlicer == null) - { - return new OperationResult - { - Success = false, - ErrorMessage = $"Slicer '{slicerName}' not found in workbook" - }; - } - - // Delete the visual slicer - targetSlicer.Delete(); - - // Note: The SlicerCache will be automatically deleted if this was the last slicer - // connected to it. Excel handles this automatically. - - return new OperationResult { Success = true }; - } - finally - { - ComUtilities.Release(ref targetSlicer); - ComUtilities.Release(ref targetCache); - ComUtilities.Release(ref slicerCaches); - } - }); - } - - #region Slicer Helper Methods - - /// <summary> - /// Result of searching for a slicer by name (avoids dynamic tuple deconstruction) - /// </summary> - private readonly struct SlicerSearchResult - { - public dynamic? Cache { get; init; } - public dynamic? Slicer { get; init; } - } - - /// <summary> - /// Finds an existing SlicerCache for a field on a specific PivotTable - /// </summary> - private static dynamic? FindExistingSlicerCache(dynamic slicerCaches, dynamic pivot, string fieldName) - { - for (int i = 1; i <= slicerCaches.Count; i++) - { - dynamic? cache = null; - try - { - cache = slicerCaches.Item(i); - - // Check if cache is for the same field - string cacheFieldName = GetSlicerCacheFieldName(cache); - if (!string.Equals(cacheFieldName, fieldName, StringComparison.OrdinalIgnoreCase)) - { - ComUtilities.Release(ref cache); - continue; - } - - // Check if this cache is connected to our PivotTable - if (IsSlicerCacheConnectedToPivot(cache, pivot)) - { - return cache; // Don't release - returning to caller - } - - ComUtilities.Release(ref cache); - } - catch (COMException) - { - // COM property access may fail for certain cache types - continue searching - ComUtilities.Release(ref cache); - } - } - - return null; - } - - /// <summary> - /// Gets the source field name from a SlicerCache - /// </summary> - private static string GetSlicerCacheFieldName(dynamic cache) - { - dynamic? sourceField = null; - try - { - // Try to get SourceName first (OLAP), then fall back to checking PivotField - try - { - string? sourceName = cache.SourceName?.ToString(); - if (!string.IsNullOrEmpty(sourceName)) - return sourceName; - } - catch (COMException) - { - // SourceName property not available for this cache type - fall back to Name - } - - // For regular slicers, get from PivotTables collection - dynamic? pivotTables = null; - try - { - pivotTables = cache.PivotTables; - if (pivotTables != null && pivotTables.Count > 0) - { - dynamic? pt = null; - try - { - pt = pivotTables.Item(1); - // The cache Name often contains the field name - string cacheName = cache.Name?.ToString() ?? string.Empty; - // SlicerCache names are typically "Slicer_FieldName" format - if (cacheName.StartsWith("Slicer_", StringComparison.OrdinalIgnoreCase)) - { - return cacheName[7..]; // Remove "Slicer_" prefix - } - return cacheName; - } - finally - { - ComUtilities.Release(ref pt); - } - } - } - finally - { - ComUtilities.Release(ref pivotTables); - } - - return cache.Name?.ToString() ?? "Unknown"; - } - finally - { - ComUtilities.Release(ref sourceField); - } - } - - /// <summary> - /// Checks if a SlicerCache is connected to a specific PivotTable. - /// Returns false for Table slicers (cache.List == true) since they don't connect to PivotTables. - /// </summary> - private static bool IsSlicerCacheConnectedToPivot(dynamic cache, dynamic targetPivot) - { - // Per MS docs: List property is true for Table slicers, false for PivotTable slicers - // https://learn.microsoft.com/en-us/office/vba/api/excel.slicercache.list - // Table slicers don't connect to PivotTables - if (cache.List == true) - { - return false; - } - - dynamic? pivotTables = null; - try - { - pivotTables = cache.PivotTables; - string targetName = targetPivot.Name?.ToString() ?? string.Empty; - - for (int i = 1; i <= pivotTables.Count; i++) - { - dynamic? pt = null; - try - { - pt = pivotTables.Item(i); - string ptName = pt.Name?.ToString() ?? string.Empty; - if (string.Equals(ptName, targetName, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - finally - { - ComUtilities.Release(ref pt); - } - } - return false; - } - finally - { - ComUtilities.Release(ref pivotTables); - } - } - - /// <summary> - /// Finds a slicer by name across all SlicerCaches - /// </summary> - private static SlicerSearchResult FindSlicerByName(dynamic slicerCaches, string slicerName) - { - for (int cacheIndex = 1; cacheIndex <= slicerCaches.Count; cacheIndex++) - { - dynamic? cache = null; - dynamic? slicers = null; - - try - { - cache = slicerCaches.Item(cacheIndex); - slicers = cache.Slicers; - - for (int slicerIndex = 1; slicerIndex <= slicers.Count; slicerIndex++) - { - dynamic? slicer = null; - try - { - slicer = slicers.Item(slicerIndex); - string name = slicer.Name?.ToString() ?? string.Empty; - - if (string.Equals(name, slicerName, StringComparison.OrdinalIgnoreCase)) - { - // Found it - return both cache and slicer (don't release) - ComUtilities.Release(ref slicers); - return new SlicerSearchResult { Cache = cache, Slicer = slicer }; - } - ComUtilities.Release(ref slicer); - } - catch (COMException) - { - // COM access may fail for certain slicer types - continue searching - ComUtilities.Release(ref slicer); - } - } - - ComUtilities.Release(ref slicers); - ComUtilities.Release(ref cache); - } - catch (COMException) - { - // COM access may fail for certain cache types - continue searching - ComUtilities.Release(ref slicers); - ComUtilities.Release(ref cache); - } - } - - return new SlicerSearchResult { Cache = null, Slicer = null }; - } - - /// <summary> - /// Builds a SlicerInfo from COM objects - /// </summary> - private static SlicerInfo BuildSlicerInfo(dynamic slicer, dynamic cache) - { - var info = new SlicerInfo - { - Name = slicer.Name?.ToString() ?? string.Empty, - Caption = slicer.Caption?.ToString() ?? string.Empty, - FieldName = GetSlicerCacheFieldName(cache), - ColumnCount = Convert.ToInt32(slicer.NumberOfColumns) - }; - - // Get sheet name and position - dynamic? parent = null; - try - { - parent = slicer.Parent; - info.SheetName = parent.Name?.ToString() ?? string.Empty; - } - finally - { - ComUtilities.Release(ref parent); - } - - // Get position (top-left cell) - per Microsoft docs, TopLeftCell is on Shape object - // https://learn.microsoft.com/en-us/office/vba/api/excel.shape.topleftcell - dynamic? shape = null; - dynamic? topLeftCell = null; - try - { - shape = slicer.Shape; - topLeftCell = shape.TopLeftCell; - info.Position = topLeftCell?.Address?.ToString()?.Replace("$", "") ?? string.Empty; - } - finally - { - ComUtilities.Release(ref topLeftCell); - ComUtilities.Release(ref shape); - } - - // Get selected and available items from cache - var items = GetSlicerItems(cache); - info.SelectedItems = items.Selected; - info.AvailableItems = items.Available; - - // Get connected PivotTables - info.ConnectedPivotTables = GetConnectedPivotTableNames(cache); - - return info; - } - - /// <summary> - /// Builds a SlicerResult from COM objects - /// </summary> - private static SlicerResult BuildSlicerResult(dynamic slicer, dynamic cache, string fieldName) - { - var result = new SlicerResult - { - Name = slicer.Name?.ToString() ?? string.Empty, - Caption = slicer.Caption?.ToString() ?? string.Empty, - FieldName = fieldName - }; - - // Get sheet name and position - dynamic? parent = null; - try - { - parent = slicer.Parent; - result.SheetName = parent.Name?.ToString() ?? string.Empty; - } - finally - { - ComUtilities.Release(ref parent); - } - - // Get position - per Microsoft docs, TopLeftCell is on Shape object - // https://learn.microsoft.com/en-us/office/vba/api/excel.shape.topleftcell - dynamic? shape = null; - dynamic? topLeftCell = null; - try - { - shape = slicer.Shape; - topLeftCell = shape.TopLeftCell; - result.Position = topLeftCell?.Address?.ToString()?.Replace("$", "") ?? string.Empty; - } - finally - { - ComUtilities.Release(ref topLeftCell); - ComUtilities.Release(ref shape); - } - - // Get items - var items = GetSlicerItems(cache); - result.SelectedItems = items.Selected; - result.AvailableItems = items.Available; - - // Get connected PivotTables - result.ConnectedPivotTables = GetConnectedPivotTableNames(cache); - - return result; - } - - /// <summary> - /// Result of getting slicer items (avoids dynamic tuple deconstruction) - /// </summary> - private readonly struct SlicerItemsResult - { - public List<string> Selected { get; init; } - public List<string> Available { get; init; } - } - - /// <summary> - /// Gets selected and available items from a SlicerCache - /// </summary> - private static SlicerItemsResult GetSlicerItems(dynamic cache) - { - var selected = new List<string>(); - var available = new List<string>(); - dynamic? slicerItems = null; - - try - { - slicerItems = cache.SlicerItems; - - for (int i = 1; i <= slicerItems.Count; i++) - { - dynamic? item = null; - try - { - item = slicerItems.Item(i); - string itemName = item.Name?.ToString() ?? string.Empty; - - if (!string.IsNullOrEmpty(itemName)) - { - available.Add(itemName); - if (item.Selected) - { - selected.Add(itemName); - } - } - } - finally - { - ComUtilities.Release(ref item); - } - } - } - catch (COMException) - { - // SlicerItems collection may not be accessible for certain cache types - } - finally - { - ComUtilities.Release(ref slicerItems); - } - - return new SlicerItemsResult { Selected = selected, Available = available }; - } - - /// <summary> - /// Gets names of PivotTables connected to a SlicerCache. - /// Returns empty list for Table slicers (cache.List == true). - /// </summary> - private static List<string> GetConnectedPivotTableNames(dynamic cache) - { - var names = new List<string>(); - - // Per MS docs: List property is true for Table slicers - // Table slicers don't have PivotTables collection - if (cache.List == true) - { - return names; - } - - dynamic? pivotTables = null; - try - { - pivotTables = cache.PivotTables; - - for (int i = 1; i <= pivotTables.Count; i++) - { - dynamic? pt = null; - try - { - pt = pivotTables.Item(i); - string name = pt.Name?.ToString() ?? string.Empty; - if (!string.IsNullOrEmpty(name)) - { - names.Add(name); - } - } - finally - { - ComUtilities.Release(ref pt); - } - } - } - finally - { - ComUtilities.Release(ref pivotTables); - } - - return names; - } - - #endregion -} - - diff --git a/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.Subtotals.cs b/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.Subtotals.cs deleted file mode 100644 index c6ed5087..00000000 --- a/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.Subtotals.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.PivotTable; - -/// <summary> -/// PivotTable subtotals operations - show/hide automatic subtotals for row fields. -/// </summary> -public partial class PivotTableCommands -{ - /// <summary> - /// Shows or hides subtotals for a specific row field. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Name of the PivotTable</param> - /// <param name="fieldName">Name of the row field</param> - /// <param name="showSubtotals">True to show automatic subtotals, false to hide</param> - /// <returns>Result with updated field configuration</returns> - /// <remarks> - /// SUBTOTALS BEHAVIOR: - /// - When enabled: Shows Automatic subtotals (uses appropriate function based on data) - /// - When disabled: Hides all subtotals for the field - /// - /// OLAP LIMITATION: - /// - OLAP PivotTables only support Automatic subtotals - /// - Regular PivotTables can choose Sum, Count, Average, etc. (future enhancement) - /// </remarks> - public PivotFieldResult SetSubtotals(IExcelBatch batch, string pivotTableName, string fieldName, bool showSubtotals) - => ExecuteWithStrategy<PivotFieldResult>(batch, pivotTableName, - (strategy, pivot) => strategy.SetSubtotals(pivot, fieldName, showSubtotals, batch.WorkbookPath, batch.Logger)); -} - - diff --git a/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.cs b/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.cs deleted file mode 100644 index 4eddd359..00000000 --- a/src/ExcelMcp.Core/Commands/PivotTable/PivotTableCommands.cs +++ /dev/null @@ -1,202 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; - -namespace Sbroenne.ExcelMcp.Core.Commands.PivotTable; - -/// <summary> -/// PivotTable management commands - main partial class with shared state and helper methods -/// </summary> -public partial class PivotTableCommands : IPivotTableCommands, IPivotTableFieldCommands, IPivotTableCalcCommands -{ - #region Helper Methods - - /// <summary> - /// Finds a PivotTable by name in the workbook. - /// Delegates to CoreLookupHelpers.FindPivotTable for the actual lookup. - /// </summary> - /// <param name="workbook">The workbook to search</param> - /// <param name="pivotTableName">Name of the PivotTable to find</param> - /// <returns>The PivotTable object if found</returns> - /// <exception cref="InvalidOperationException">Thrown if PivotTable is not found</exception> - private static dynamic FindPivotTable(dynamic workbook, string pivotTableName) - => CoreLookupHelpers.FindPivotTable(workbook, pivotTableName); - - /// <summary> - /// Executes a strategy-based operation on a PivotTable. - /// Centralizes the common pattern: find pivot → get strategy → execute → release. - /// </summary> - /// <typeparam name="TResult">The result type returned by the strategy operation</typeparam> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Name of the PivotTable</param> - /// <param name="operation">The strategy operation to execute (receives strategy and pivot)</param> - /// <returns>The result from the strategy operation</returns> - private static TResult ExecuteWithStrategy<TResult>( - IExcelBatch batch, - string pivotTableName, - Func<IPivotTableFieldStrategy, dynamic, TResult> operation) - { - return batch.Execute((ctx, ct) => - { - dynamic? pivot = null; - try - { - pivot = FindPivotTable(ctx.Book, pivotTableName); - var strategy = PivotTableFieldStrategyFactory.GetStrategy(pivot); - return operation(strategy, pivot); - } - finally - { - ComUtilities.Release(ref pivot); - } - }); - } - - /// <summary> - /// Gets all field names from a PivotTable - /// </summary> - private static List<string> GetFieldNames(dynamic pivotTable) - { - var fieldNames = new List<string>(); - dynamic? pivotFields = null; - try - { - pivotFields = pivotTable.PivotFields; - for (int i = 1; i <= pivotFields.Count; i++) - { - dynamic? field = null; - try - { - field = pivotFields.Item(i); - fieldNames.Add(field.SourceName?.ToString() ?? field.Name?.ToString() ?? $"Field{i}"); - } - finally - { - ComUtilities.Release(ref field); - } - } - } - finally - { - ComUtilities.Release(ref pivotFields); - } - return fieldNames; - } - - /// <summary> - /// Gets a field for manipulation, handling both OLAP and regular PivotTables. - /// For OLAP PivotTables, accesses via CubeFields and returns the corresponding PivotField. - /// For regular PivotTables, accesses via PivotFields directly. - /// </summary> - /// <param name="pivot">The PivotTable object</param> - /// <param name="fieldName">Name of the field to retrieve</param> - /// <param name="isOlap">Output parameter indicating if this is an OLAP PivotTable</param> - /// <returns>The field object that can be manipulated (PivotField)</returns> - /// <exception cref="InvalidOperationException">Thrown if field is not found</exception> - /// <remarks> - /// Microsoft docs: "In OLAP PivotTables, PivotFields do not exist until the corresponding - /// CubeField is added to the PivotTable." This method handles both architectures. - /// </remarks> - private static dynamic GetFieldForManipulation(dynamic pivot, string fieldName, out bool isOlap) - { - isOlap = false; - dynamic? cubeFields = null; - - try - { - // Check if this is an OLAP/Data Model PivotTable - isOlap = PivotTableHelpers.TryGetCubeFields(pivot, out cubeFields); - - if (isOlap) - { - // OLAP PivotTable - access via CubeFields - // CubeField names are hierarchical like "[TableName].[FieldName]" or "[Measures].[MeasureName]" - // EXACT MATCH ONLY - no partial matching to avoid disambiguation bugs - dynamic? cubeField = null; - try - { - // Exact match only - the LLM knows the exact field names - try - { - cubeField = cubeFields.Item(fieldName); - } - catch (System.Runtime.InteropServices.COMException) - { - // Field not found by exact name - cubeField = null; - } - - if (cubeField == null) - { - throw new InvalidOperationException($"CubeField '{fieldName}' not found in Data Model PivotTable. Use the exact CubeField name (e.g., '[Measures].[ACR]' or '[TableName].[ColumnName]')."); - } - - // Get or create the PivotField from the CubeField - // Per Microsoft docs: CubeField.PivotFields returns collection of PivotFields for this CubeField - dynamic? pivotFields = cubeField.PivotFields; - if (pivotFields == null || pivotFields.Count == 0) - { - // No PivotField exists yet - field hasn't been added to PivotTable - // Call CreatePivotFields() to create the PivotFields collection - // Per Microsoft docs: "In OLAP PivotTables, PivotFields do not exist until - // the corresponding CubeField is added to the PivotTable. The CreatePivotFields() - // method enables users to create all PivotFields of a CubeField." - ComUtilities.Release(ref pivotFields); - cubeField.CreatePivotFields(); // Create PivotFields before manipulation - - // Now get the newly created PivotFields collection - pivotFields = cubeField.PivotFields; - if (pivotFields == null || pivotFields.Count == 0) - { - // Still no PivotFields - this shouldn't happen after CreatePivotFields() - ComUtilities.Release(ref pivotFields); - throw new InvalidOperationException($"Failed to create PivotFields for CubeField '{fieldName}'"); - } - } - - // Release PivotFields collection - we don't need it - ComUtilities.Release(ref pivotFields); - - // CRITICAL: Return the CubeField, not the PivotField! - // For OLAP, we must set Orientation on the CubeField, not on the PivotField. - // Microsoft docs: "CubeField.Orientation returns or sets... the location of the field" - // Setting PivotField.Orientation fails with "Unable to set the Orientation property" - // DON'T release cubeField - caller needs it! - return cubeField; - } - finally - { - // Only release cubeField if we didn't return it or a child object - // Since we return cubeField, we should NOT release it here - // if (cubeField != null) - // ComUtilities.Release(ref cubeField); - } - } - else - { - // Regular PivotTable - access via PivotFields directly - dynamic? pivotFields = null; - try - { - pivotFields = pivot.PivotFields; - return pivotFields.Item(fieldName); // COM will throw if not found - } - catch (Exception ex) - { - throw new InvalidOperationException($"Field '{fieldName}' not found in PivotTable", ex); - } - finally - { - ComUtilities.Release(ref pivotFields); - } - } - } - finally - { - ComUtilities.Release(ref cubeFields); - } - } - - #endregion -} - - diff --git a/src/ExcelMcp.Core/Commands/PivotTable/PivotTableFieldStrategyFactory.cs b/src/ExcelMcp.Core/Commands/PivotTable/PivotTableFieldStrategyFactory.cs deleted file mode 100644 index d7bfe458..00000000 --- a/src/ExcelMcp.Core/Commands/PivotTable/PivotTableFieldStrategyFactory.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; - -namespace Sbroenne.ExcelMcp.Core.Commands.PivotTable; - -/// <summary> -/// Factory for creating appropriate PivotTable field strategy based on PivotTable type -/// </summary> -public static class PivotTableFieldStrategyFactory -{ - private static readonly List<IPivotTableFieldStrategy> _strategies = new() - { - new OlapPivotTableFieldStrategy(), // Check OLAP first (more specific) - new RegularPivotTableFieldStrategy() // Fallback to regular - }; - - /// <summary> - /// Gets the appropriate strategy for the given PivotTable - /// </summary> - /// <param name="pivot">The PivotTable object</param> - /// <returns>Strategy that can handle this PivotTable type</returns> - /// <exception cref="InvalidOperationException">If no strategy can handle the PivotTable</exception> - public static IPivotTableFieldStrategy GetStrategy(dynamic pivot) - { - if (pivot == null) - throw new InvalidOperationException("PivotTable object is null"); - - // Try OLAP first (more specific) - if (PivotTableHelpers.IsOlapPivotTable(pivot)) - { - return _strategies[0]; // OlapPivotTableFieldStrategy - } - - // Fall back to Regular - try - { - dynamic? pivotFields = pivot.PivotFields; - if (pivotFields != null) - { - ComUtilities.Release(ref pivotFields); - return _strategies[1]; // RegularPivotTableFieldStrategy - } - ComUtilities.Release(ref pivotFields); - } - catch (System.Runtime.InteropServices.COMException) - { - // Not Regular either - } - - throw new InvalidOperationException("No strategy found for PivotTable type. Unable to determine if OLAP or Regular PivotTable."); - } -} - - diff --git a/src/ExcelMcp.Core/Commands/PivotTable/PivotTableHelpers.cs b/src/ExcelMcp.Core/Commands/PivotTable/PivotTableHelpers.cs deleted file mode 100644 index e4114acf..00000000 --- a/src/ExcelMcp.Core/Commands/PivotTable/PivotTableHelpers.cs +++ /dev/null @@ -1,215 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.PivotTable; - -/// <summary> -/// Shared helper methods for PivotTable operations. -/// Centralizes common patterns to avoid cargo cult duplication. -/// </summary> -internal static class PivotTableHelpers -{ - /// <summary> - /// Gets the area name for display purposes from a pivot field orientation. - /// </summary> - public static string GetAreaName(dynamic orientation) - { - int orientationValue = Convert.ToInt32(orientation); - return orientationValue switch - { - XlPivotFieldOrientation.xlHidden => "Hidden", - XlPivotFieldOrientation.xlRowField => "Row", - XlPivotFieldOrientation.xlColumnField => "Column", - XlPivotFieldOrientation.xlPageField => "Filter", - XlPivotFieldOrientation.xlDataField => "Value", - _ => $"Unknown({orientationValue})" - }; - } - - /// <summary> - /// Determines if a PivotTable is OLAP-based (Data Model/PowerPivot). - /// OLAP PivotTables use CubeFields for field manipulation, while regular - /// PivotTables use PivotFields. - /// </summary> - /// <param name="pivot">The PivotTable COM object</param> - /// <returns>True if the PivotTable is OLAP-based, false otherwise</returns> - /// <remarks> - /// This helper consolidates the duplicated pattern: - /// cubeFields = pivot.CubeFields; - /// isOlap = cubeFields != null && cubeFields.Count > 0; - /// - /// Note: Does NOT release cubeFields - caller may need them. - /// Use TryGetCubeFields for patterns that need the COM object. - /// </remarks> - public static bool IsOlapPivotTable(dynamic pivot) - { - dynamic? cubeFields = null; - try - { - cubeFields = pivot.CubeFields; - return cubeFields != null && cubeFields.Count > 0; - } -#pragma warning disable CA1031 // COM interop: CubeFields property doesn't exist on non-OLAP PivotTables - catch (System.Runtime.InteropServices.COMException) - { - // CubeFields property not available or failed - not an OLAP PivotTable - return false; - } -#pragma warning restore CA1031 - finally - { - ComUtilities.Release(ref cubeFields); - } - } - - /// <summary> - /// Attempts to get CubeFields from a PivotTable for OLAP operations. - /// Returns the cubeFields object along with the OLAP status. - /// </summary> - /// <param name="pivot">The PivotTable COM object</param> - /// <param name="cubeFields">Output: The CubeFields collection (caller must release)</param> - /// <returns>True if OLAP with valid CubeFields, false otherwise</returns> - /// <remarks> - /// IMPORTANT: Caller is responsible for releasing cubeFields via ComUtilities.Release(). - /// Use this when you need the cubeFields object for subsequent operations. - /// </remarks> - public static bool TryGetCubeFields(dynamic pivot, out dynamic? cubeFields) - { - cubeFields = null; - try - { - cubeFields = pivot.CubeFields; - return cubeFields != null && cubeFields.Count > 0; - } -#pragma warning disable CA1031 // COM interop: CubeFields property doesn't exist on non-OLAP PivotTables - catch (System.Runtime.InteropServices.COMException) - { - // CubeFields property not available - not an OLAP PivotTable - // cubeFields already null from initialization - return false; - } -#pragma warning restore CA1031 - } - - /// <summary> - /// Converts Excel COM constant to AggregationFunction enum. - /// </summary> - public static AggregationFunction GetAggregationFunctionFromCom(int comFunction) - { - return comFunction switch - { - XlConsolidationFunction.xlSum => AggregationFunction.Sum, - XlConsolidationFunction.xlCount => AggregationFunction.Count, - XlConsolidationFunction.xlAverage => AggregationFunction.Average, - XlConsolidationFunction.xlMax => AggregationFunction.Max, - XlConsolidationFunction.xlMin => AggregationFunction.Min, - XlConsolidationFunction.xlProduct => AggregationFunction.Product, - XlConsolidationFunction.xlCountNums => AggregationFunction.CountNumbers, - XlConsolidationFunction.xlStdDev => AggregationFunction.StdDev, - XlConsolidationFunction.xlStdDevP => AggregationFunction.StdDevP, - XlConsolidationFunction.xlVar => AggregationFunction.Var, - XlConsolidationFunction.xlVarP => AggregationFunction.VarP, - _ => throw new InvalidOperationException($"Unknown COM aggregation function: {comFunction}") - }; - } - - /// <summary> - /// Detects the data type of a PivotField by sampling its values. - /// </summary> - /// <param name="field">The PivotField COM object</param> - /// <returns>Data type string: "Date", "Number", "Boolean", "Text", or "Unknown"</returns> - public static string DetectFieldDataType(dynamic field) - { - dynamic? pivotItems = null; - try - { - pivotItems = field.PivotItems; - var sampleValues = new List<object?>(); - - int sampleCount = Math.Min(10, pivotItems.Count); - for (int i = 1; i <= sampleCount; i++) - { - dynamic? item = null; - try - { - item = pivotItems.Item(i); - var value = item.Value; - if (value != null) - sampleValues.Add(value); - } - finally - { - ComUtilities.Release(ref item); - } - } - - // Analyze sample values - if (sampleValues.Count == 0) - return "Unknown"; - - if (sampleValues.All(v => DateTime.TryParse(v?.ToString(), out _))) - return "Date"; - if (sampleValues.All(v => double.TryParse(v?.ToString(), out _))) - return "Number"; - if (sampleValues.All(v => bool.TryParse(v?.ToString(), out _))) - return "Boolean"; - - return "Text"; - } -#pragma warning disable CA1031 // COM interop: PivotItems may not be accessible on all field types - catch (System.Runtime.InteropServices.COMException) - { - // PivotItems access failed - cannot determine data type - return "Unknown"; - } -#pragma warning restore CA1031 - finally - { - ComUtilities.Release(ref pivotItems); - } - } - - /// <summary> - /// Gets unique values from a PivotField for filtering purposes. - /// Iterates through PivotItems to collect available values. - /// </summary> - /// <param name="field">The PivotField COM object</param> - /// <returns>List of unique value strings from the field</returns> - public static List<string> GetFieldUniqueValues(dynamic field) - { - var values = new List<string>(); - dynamic? pivotItems = null; - try - { - pivotItems = field.PivotItems; - for (int i = 1; i <= pivotItems.Count; i++) - { - dynamic? item = null; - try - { - item = pivotItems.Item(i); - string itemName = item.Name?.ToString() ?? string.Empty; - if (!string.IsNullOrEmpty(itemName)) - values.Add(itemName); - } - finally - { - ComUtilities.Release(ref item); - } - } - } -#pragma warning disable CA1031 // COM interop: PivotItems may not be accessible on all field types - catch (System.Runtime.InteropServices.COMException) - { - // PivotItems access failed - return partial list - } -#pragma warning restore CA1031 - finally - { - ComUtilities.Release(ref pivotItems); - } - return values; - } -} - - diff --git a/src/ExcelMcp.Core/Commands/PivotTable/RegularPivotTableFieldStrategy.cs b/src/ExcelMcp.Core/Commands/PivotTable/RegularPivotTableFieldStrategy.cs deleted file mode 100644 index 19567c78..00000000 --- a/src/ExcelMcp.Core/Commands/PivotTable/RegularPivotTableFieldStrategy.cs +++ /dev/null @@ -1,962 +0,0 @@ -using Microsoft.Extensions.Logging; -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.PivotTable; - -/// <summary> -/// Strategy for Regular (non-OLAP) PivotTable field operations. -/// Uses PivotFields API for range-based and table-based PivotTables. -/// </summary> -public class RegularPivotTableFieldStrategy : IPivotTableFieldStrategy -{ - /// <inheritdoc/> - public bool CanHandle(dynamic pivot) - { - // Regular PivotTables are NOT OLAP and have PivotFields - if (PivotTableHelpers.IsOlapPivotTable(pivot)) - return false; // This is OLAP, not regular - - try - { - dynamic pivotFields = pivot.PivotFields; - return pivotFields != null; - } - catch (System.Runtime.InteropServices.COMException) - { - return false; - } - } - - /// <inheritdoc/> - public dynamic GetFieldForManipulation(dynamic pivot, string fieldName) - { - dynamic? pivotFields = null; - try - { - pivotFields = pivot.PivotFields; - return pivotFields.Item(fieldName); // COM will throw if not found - } - catch (Exception ex) - { - throw new InvalidOperationException($"Field '{fieldName}' not found in PivotTable", ex); - } - finally - { - ComUtilities.Release(ref pivotFields); - } - } - - /// <inheritdoc/> - public PivotFieldListResult ListFields(dynamic pivot, string workbookPath) - { - var fields = new List<PivotFieldInfo>(); - dynamic? pivotFields = null; - - try - { - pivotFields = pivot.PivotFields; - int fieldCount = pivotFields.Count; - - for (int i = 1; i <= fieldCount; i++) - { - dynamic? field = null; - try - { - field = pivotFields.Item(i); - int orientation = Convert.ToInt32(field.Orientation); - - var fieldInfo = new PivotFieldInfo - { - Name = field.SourceName?.ToString() ?? field.Name?.ToString() ?? $"Field{i}", - CustomName = field.Caption?.ToString() ?? "", - Area = (PivotFieldArea)orientation, - DataType = PivotTableHelpers.DetectFieldDataType(field) - }; - - // For value fields, get function from DataFields - if (orientation == XlPivotFieldOrientation.xlDataField) - { - int comFunction = Convert.ToInt32(field.Function); - fieldInfo.Function = PivotTableHelpers.GetAggregationFunctionFromCom(comFunction); - } - - fields.Add(fieldInfo); - } - catch (System.Runtime.InteropServices.COMException) - { - // Individual field access failed - skip this field and continue - // This can happen with calculated fields or special field types - } - finally - { - ComUtilities.Release(ref field); - } - } - - return new PivotFieldListResult - { - Success = true, - Fields = fields, - FilePath = workbookPath - }; - } - finally - { - ComUtilities.Release(ref pivotFields); - } - } - - /// <inheritdoc/> - public PivotFieldResult AddRowField(dynamic pivot, string fieldName, int? position, string workbookPath) - { - dynamic? field = null; - try - { - field = GetFieldForManipulation(pivot, fieldName); - - // Check if field is already placed - int currentOrientation = Convert.ToInt32(field.Orientation); - if (currentOrientation != XlPivotFieldOrientation.xlHidden) - { - throw new InvalidOperationException($"Field '{fieldName}' is already placed in {PivotTableHelpers.GetAreaName(currentOrientation)} area. Remove it first."); - } - - // Add to Row area - field.Orientation = XlPivotFieldOrientation.xlRowField; - if (position.HasValue) - { - field.Position = (double)position.Value; - } - - // NOTE: No RefreshTable() needed - orientation change takes effect immediately - // Removed for consistency with OLAP strategy and improved performance (issue #426) - - if (field.Orientation != XlPivotFieldOrientation.xlRowField) - { - throw new InvalidOperationException($"Field '{fieldName}' was not successfully added to Row area."); - } - - return new PivotFieldResult - { - Success = true, - FieldName = fieldName, - CustomName = field.Caption?.ToString() ?? fieldName, - Area = PivotFieldArea.Row, - Position = Convert.ToInt32(field.Position), - DataType = PivotTableHelpers.DetectFieldDataType(field), - AvailableValues = PivotTableHelpers.GetFieldUniqueValues(field), - FilePath = workbookPath - }; - } - finally - { - ComUtilities.Release(ref field); - } - } - - /// <inheritdoc/> - public PivotFieldResult AddColumnField(dynamic pivot, string fieldName, int? position, string workbookPath) - { - dynamic? field = null; - try - { - field = GetFieldForManipulation(pivot, fieldName); - - int currentOrientation = Convert.ToInt32(field.Orientation); - if (currentOrientation != XlPivotFieldOrientation.xlHidden) - { - throw new InvalidOperationException($"Field '{fieldName}' is already placed in {PivotTableHelpers.GetAreaName(currentOrientation)} area. Remove it first."); - } - - field.Orientation = XlPivotFieldOrientation.xlColumnField; - if (position.HasValue) - { - field.Position = (double)position.Value; - } - - // NOTE: No RefreshTable() needed - orientation change takes effect immediately - // Removed for consistency with OLAP strategy and improved performance (issue #426) - - if (field.Orientation != XlPivotFieldOrientation.xlColumnField) - { - throw new InvalidOperationException($"Field '{fieldName}' was not successfully added to Column area."); - } - - return new PivotFieldResult - { - Success = true, - FieldName = fieldName, - CustomName = field.Caption?.ToString() ?? fieldName, - Area = PivotFieldArea.Column, - Position = Convert.ToInt32(field.Position), - DataType = PivotTableHelpers.DetectFieldDataType(field), - AvailableValues = PivotTableHelpers.GetFieldUniqueValues(field), - FilePath = workbookPath - }; - } - finally - { - ComUtilities.Release(ref field); - } - } - - /// <inheritdoc/> - public PivotFieldResult AddValueField(dynamic pivot, string fieldName, AggregationFunction aggregationFunction, string? customName, string workbookPath) - { - dynamic? field = null; - try - { - field = GetFieldForManipulation(pivot, fieldName); - - // Validate aggregation function for field data type - string dataType = PivotTableHelpers.DetectFieldDataType(field); - if (!IsValidAggregationForDataType(aggregationFunction, dataType)) - { - var validFunctions = GetValidAggregationsForDataType(dataType); - throw new InvalidOperationException($"Aggregation function '{aggregationFunction}' is not valid for {dataType} field '{fieldName}'. Valid functions: {string.Join(", ", validFunctions)}"); - } - - int currentOrientation = Convert.ToInt32(field.Orientation); - if (currentOrientation != XlPivotFieldOrientation.xlHidden) - { - throw new InvalidOperationException($"Field '{fieldName}' is already placed in {PivotTableHelpers.GetAreaName(currentOrientation)} area. Remove it first."); - } - - field.Orientation = XlPivotFieldOrientation.xlDataField; - int comFunction = GetComAggregationFunction(aggregationFunction); - field.Function = comFunction; - - if (!string.IsNullOrEmpty(customName)) - { - field.Caption = customName; - } - - // NOTE: No RefreshTable() needed - field changes take effect immediately - // Removed for consistency with OLAP strategy and improved performance (issue #426) - - return new PivotFieldResult - { - Success = true, - FieldName = fieldName, - CustomName = field.Caption?.ToString() ?? fieldName, - Area = PivotFieldArea.Value, - Function = aggregationFunction, - DataType = dataType, - FilePath = workbookPath - }; - } - finally - { - ComUtilities.Release(ref field); - } - } - - /// <inheritdoc/> - public PivotFieldResult AddFilterField(dynamic pivot, string fieldName, string workbookPath) - { - dynamic? field = null; - try - { - field = GetFieldForManipulation(pivot, fieldName); - - int currentOrientation = Convert.ToInt32(field.Orientation); - if (currentOrientation != XlPivotFieldOrientation.xlHidden) - { - throw new InvalidOperationException($"Field '{fieldName}' is already placed in {PivotTableHelpers.GetAreaName(currentOrientation)} area. Remove it first."); - } - - field.Orientation = XlPivotFieldOrientation.xlPageField; - - // NOTE: No RefreshTable() needed - orientation change takes effect immediately - // Removed for consistency with OLAP strategy and improved performance (issue #426) - - if (field.Orientation != XlPivotFieldOrientation.xlPageField) - { - throw new InvalidOperationException($"Field '{fieldName}' was not successfully added to Filter area."); - } - - return new PivotFieldResult - { - Success = true, - FieldName = fieldName, - CustomName = field.Caption?.ToString() ?? fieldName, - Area = PivotFieldArea.Filter, - Position = Convert.ToInt32(field.Position), - DataType = PivotTableHelpers.DetectFieldDataType(field), - AvailableValues = PivotTableHelpers.GetFieldUniqueValues(field), - FilePath = workbookPath - }; - } - finally - { - ComUtilities.Release(ref field); - } - } - - /// <inheritdoc/> - public PivotFieldResult RemoveField(dynamic pivot, string fieldName, string workbookPath) - { - dynamic? field = null; - try - { - field = GetFieldForManipulation(pivot, fieldName); - - int currentOrientation = Convert.ToInt32(field.Orientation); - if (currentOrientation == XlPivotFieldOrientation.xlHidden) - { - throw new InvalidOperationException($"Field '{fieldName}' is not currently placed in any area"); - } - - field.Orientation = XlPivotFieldOrientation.xlHidden; - - // NOTE: No RefreshTable() needed - orientation change takes effect immediately - // Removed for consistency with OLAP strategy and improved performance (issue #426) - - return new PivotFieldResult - { - Success = true, - FieldName = fieldName, - Area = PivotFieldArea.Hidden, - FilePath = workbookPath - }; - } - finally - { - ComUtilities.Release(ref field); - } - } - - /// <inheritdoc/> - public PivotFieldResult SetFieldName(dynamic pivot, string fieldName, string customName, string workbookPath) - { - dynamic? field = null; - try - { - field = GetFieldForManipulation(pivot, fieldName); - field.Caption = customName; - - // NOTE: No RefreshTable() needed - Caption is a visual-only property - - return new PivotFieldResult - { - Success = true, - FieldName = fieldName, - CustomName = customName, - Area = (PivotFieldArea)field.Orientation, - FilePath = workbookPath - }; - } - finally - { - ComUtilities.Release(ref field); - } - } - - /// <inheritdoc/> - public PivotFieldResult SetFieldFunction(dynamic pivot, string fieldName, AggregationFunction aggregationFunction, string workbookPath) - { - dynamic? field = null; - try - { - // Find field in DataFields collection (value fields) - bool foundInDataFields = false; - for (int i = 1; i <= pivot.DataFields.Count; i++) - { - dynamic? dataField = null; - try - { - dataField = pivot.DataFields.Item(i); - string sourceName = dataField.SourceName?.ToString() ?? ""; - if (sourceName == fieldName) - { - field = dataField; - foundInDataFields = true; - break; - } - } - finally - { - if (!foundInDataFields && dataField != null) - ComUtilities.Release(ref dataField); - } - } - - if (!foundInDataFields) - { - field = GetFieldForManipulation(pivot, fieldName); - int orientation = Convert.ToInt32(field.Orientation); - if (orientation != XlPivotFieldOrientation.xlDataField) - { - throw new InvalidOperationException($"Field '{fieldName}' is not in the Values area. It is in {PivotTableHelpers.GetAreaName(orientation)} area."); - } - } - - // Get source field for data type detection - dynamic? sourceField = GetFieldForManipulation(pivot, fieldName); - string dataType = PivotTableHelpers.DetectFieldDataType(sourceField); - ComUtilities.Release(ref sourceField); - - if (!IsValidAggregationForDataType(aggregationFunction, dataType)) - { - var validFunctions = GetValidAggregationsForDataType(dataType); - throw new InvalidOperationException($"Aggregation function '{aggregationFunction}' is not valid for {dataType} field '{fieldName}'. Valid functions: {string.Join(", ", validFunctions)}"); - } - - int comFunction = GetComAggregationFunction(aggregationFunction); - field.Function = comFunction; - // NOTE: No RefreshTable() needed - Function change persists without refresh - // (Verified by diagnostic test: FunctionChange_WithoutRefresh_VerifyPersistence) - - return new PivotFieldResult - { - Success = true, - FieldName = fieldName, - CustomName = field.Caption?.ToString() ?? fieldName, - Area = PivotFieldArea.Value, - Function = aggregationFunction, - DataType = dataType, - FilePath = workbookPath - }; - } - finally - { - ComUtilities.Release(ref field); - } - } - - /// <inheritdoc/> - public PivotFieldResult SetFieldFormat(dynamic pivot, string fieldName, string numberFormat, string workbookPath) - { - dynamic? field = null; - try - { - // Find field in DataFields collection - bool foundInDataFields = false; - for (int i = 1; i <= pivot.DataFields.Count; i++) - { - dynamic? dataField = null; - try - { - dataField = pivot.DataFields.Item(i); - string sourceName = dataField.SourceName?.ToString() ?? ""; - if (sourceName == fieldName) - { - field = dataField; - foundInDataFields = true; - break; - } - } - finally - { - if (!foundInDataFields && dataField != null) - ComUtilities.Release(ref dataField); - } - } - - if (!foundInDataFields) - { - field = GetFieldForManipulation(pivot, fieldName); - int orientation = Convert.ToInt32(field.Orientation); - if (orientation != XlPivotFieldOrientation.xlDataField) - { - throw new InvalidOperationException($"Field '{fieldName}' is not in the Values area. Only value fields can have number formats."); - } - } - - field.NumberFormat = numberFormat; - - // NOTE: No RefreshTable() needed - NumberFormat is a visual-only property - - // Read back the format to verify it was set - string? appliedFormat = null; - try - { - appliedFormat = field.NumberFormat?.ToString(); - } - catch (System.Runtime.InteropServices.COMException) - { - // If we can't read it back, use what we set - appliedFormat = numberFormat; - } - - return new PivotFieldResult - { - Success = true, - FieldName = fieldName, - CustomName = field.Caption?.ToString() ?? fieldName, - Area = PivotFieldArea.Value, - NumberFormat = appliedFormat, - FilePath = workbookPath - }; - } - finally - { - ComUtilities.Release(ref field); - } - } - - /// <inheritdoc/> - public PivotFieldFilterResult SetFieldFilter(dynamic pivot, string fieldName, List<string> filterValues, string workbookPath) - { - dynamic? field = null; - dynamic? pivotItems = null; - try - { - field = GetFieldForManipulation(pivot, fieldName); - - // Clear all existing filters first - field.ClearAllFilters(); - - // Set visibility based on filter values - pivotItems = field.PivotItems; - var availableItems = new List<string>(); - - for (int i = 1; i <= pivotItems.Count; i++) - { - dynamic? item = null; - try - { - item = pivotItems.Item(i); - string itemName = item.Name?.ToString() ?? ""; - availableItems.Add(itemName); - item.Visible = filterValues.Contains(itemName); - } - finally - { - ComUtilities.Release(ref item); - } - } - - // NOTE: No RefreshTable() needed - Filter changes persist without refresh - // (Verified by diagnostic test: Filter_WithoutRefresh_VerifyPersistence) - - return new PivotFieldFilterResult - { - Success = true, - FieldName = fieldName, - SelectedItems = filterValues, - AvailableItems = availableItems, - ShowAll = false, - FilePath = workbookPath - }; - } - finally - { - ComUtilities.Release(ref pivotItems); - ComUtilities.Release(ref field); - } - } - - /// <inheritdoc/> - public PivotFieldResult SortField(dynamic pivot, string fieldName, SortDirection direction, string workbookPath) - { - dynamic? field = null; - try - { - field = GetFieldForManipulation(pivot, fieldName); - - int sortOrder = direction == SortDirection.Ascending - ? XlSortOrder.xlAscending - : XlSortOrder.xlDescending; - - field.AutoSort(sortOrder, fieldName); - - // NOTE: No RefreshTable() needed - Sorting is a visual-only operation - - return new PivotFieldResult - { - Success = true, - FieldName = fieldName, - CustomName = field.Caption?.ToString() ?? fieldName, - Area = (PivotFieldArea)field.Orientation, - FilePath = workbookPath - }; - } - finally - { - ComUtilities.Release(ref field); - } - } - - #region Helper Methods - - private static bool IsValidAggregationForDataType(AggregationFunction function, string dataType) - { - return dataType switch - { - "Number" => true, - "Date" => function is AggregationFunction.Count or AggregationFunction.CountNumbers or - AggregationFunction.Max or AggregationFunction.Min, - "Text" => function == AggregationFunction.Count, - "Boolean" => function is AggregationFunction.Count or AggregationFunction.Sum, - _ => function == AggregationFunction.Count - }; - } - - private static List<string> GetValidAggregationsForDataType(string dataType) - { - // NOTE: "Unknown" type includes calculated fields where we can't determine the data type. - // Calculated fields typically produce numeric results, so we allow all numeric aggregations. - // If the aggregation is truly invalid, Excel COM will throw an error at runtime. - return dataType switch - { - "Number" => ["Sum", "Count", "Average", "Max", "Min", "Product", "CountNumbers", "StdDev", "StdDevP", "Var", "VarP"], - "Date" => ["Count", "CountNumbers", "Max", "Min"], - "Text" => ["Count"], - "Boolean" => ["Count", "Sum"], - // Unknown = calculated fields or fields we can't inspect - allow all numeric operations - _ => ["Sum", "Count", "Average", "Max", "Min", "Product", "CountNumbers", "StdDev", "StdDevP", "Var", "VarP"] - }; - } - - private static int GetComAggregationFunction(AggregationFunction function) - { - return function switch - { - AggregationFunction.Sum => XlConsolidationFunction.xlSum, - AggregationFunction.Count => XlConsolidationFunction.xlCount, - AggregationFunction.Average => XlConsolidationFunction.xlAverage, - AggregationFunction.Max => XlConsolidationFunction.xlMax, - AggregationFunction.Min => XlConsolidationFunction.xlMin, - AggregationFunction.Product => XlConsolidationFunction.xlProduct, - AggregationFunction.CountNumbers => XlConsolidationFunction.xlCountNums, - AggregationFunction.StdDev => XlConsolidationFunction.xlStdDev, - AggregationFunction.StdDevP => XlConsolidationFunction.xlStdDevP, - AggregationFunction.Var => XlConsolidationFunction.xlVar, - AggregationFunction.VarP => XlConsolidationFunction.xlVarP, - _ => throw new InvalidOperationException($"Unsupported aggregation function: {function}") - }; - } - - #endregion - - /// <inheritdoc/> - /// <remarks> - /// CRITICAL REQUIREMENT: Source data MUST be formatted with date NumberFormat BEFORE creating the PivotTable. - /// Without proper date formatting, Excel stores dates as serial numbers (e.g., 45672) with "Standard" format, - /// which prevents date grouping from working. - /// - /// Example: - /// <code> - /// // Apply date format to source data BEFORE creating PivotTable - /// sheet.Range["D2:D6"].NumberFormat = "m/d/yyyy"; - /// </code> - /// - /// This method groups date fields by Days, Months, Quarters, or Years. Excel automatically creates - /// hierarchical groupings (e.g., Months + Years) for proper time-based analysis. - /// </remarks> - public PivotFieldResult GroupByDate(dynamic pivot, string fieldName, DateGroupingInterval interval, string workbookPath, ILogger? logger = null) - { - dynamic? field = null; - dynamic? singleCell = null; - try - { - // CRITICAL: Refresh PivotTable FIRST to populate field with actual date values - // Excel needs populated items before grouping can work - pivot.RefreshTable(); - - field = GetFieldForManipulation(pivot, fieldName); - - // CRITICAL: Microsoft documentation states: - // "The Range object must be a single cell in the PivotTable field's data range" - // This means a cell from the actual PivotTable BODY (items in the field), - // NOT the field button area. - // - // Source: https://learn.microsoft.com/en-us/dotnet/api/microsoft.office.interop.excel.range.group?view=excel-pia - // - // PivotField.DataRange returns: - // - For Row/Column/Page fields: "Items in the field" (what we need!) - // - For Data fields: "Data contained in the field" - // - // Use the first cell from field.DataRange - this is where the actual date values appear - - // Get first cell from field.DataRange (items in the field) - singleCell = field.DataRange.Cells[1, 1]; - - // CRITICAL: Periods is a boolean array with 7 elements (Seconds, Minutes, Hours, Days, Months, Quarters, Years) - // See: https://learn.microsoft.com/en-us/office/vba/api/excel.range.group - // Element indexes: 1=Seconds, 2=Minutes, 3=Hours, 4=Days, 5=Months, 6=Quarters, 7=Years - // Excel uses 1-based indexing, C# arrays are 0-based, so index 3 = element 4 = Days - var periods = new object[] { false, false, false, false, false, false, false }; - - switch (interval) - { - case DateGroupingInterval.Days: - periods[3] = true; // Element 4 (index 3) = Days - break; - case DateGroupingInterval.Months: - periods[4] = true; // Element 5 (index 4) = Months - periods[6] = true; // Element 7 (index 6) = Years (required for month grouping) - break; - case DateGroupingInterval.Quarters: - periods[5] = true; // Element 6 (index 5) = Quarters - periods[6] = true; // Element 7 (index 6) = Years (required for quarter grouping) - break; - case DateGroupingInterval.Years: - periods[6] = true; // Element 7 (index 6) = Years - break; - default: - throw new ArgumentException($"Unknown grouping interval: {interval}"); - } - - // Call Group on single cell, not entire range - // VBA examples use Start:=True and End:=True to use auto-detected min/max date range - singleCell.Group( - Start: true, - End: true, - By: Type.Missing, - Periods: periods - ); - - return new PivotFieldResult - { - Success = true, - FieldName = fieldName, - CustomName = field.Caption?.ToString() ?? fieldName, - Area = (PivotFieldArea)field.Orientation, - FilePath = workbookPath, - WorkflowHint = $"Field '{fieldName}' grouped by {interval}. Excel created automatic date hierarchy." - }; - } - catch (Exception ex) - { - if (logger is not null && logger.IsEnabled(LogLevel.Error)) - { - logger.LogError(ex, "GroupByDate failed for field '{FieldName}'", fieldName); - } - return new PivotFieldResult - { - Success = false, - ErrorMessage = $"Failed to group field by date: {ex.Message}", - FilePath = workbookPath - }; - } - finally - { - ComUtilities.Release(ref singleCell); - ComUtilities.Release(ref field); - } - } - - /// <inheritdoc/> - public PivotFieldResult GroupByNumeric(dynamic pivot, string fieldName, double? start, double? endValue, double intervalSize, string workbookPath, ILogger? logger = null) - { - dynamic? field = null; - dynamic? singleCell = null; - try - { - // CRITICAL: Refresh PivotTable FIRST to populate field with actual numeric values - // Excel needs populated items before grouping can work (same as date grouping) - pivot.RefreshTable(); - - field = GetFieldForManipulation(pivot, fieldName); - - // CRITICAL: Microsoft documentation states: - // "The Range object must be a single cell in the PivotTable field's data range" - // Same requirement as date grouping - use first cell from field.DataRange - // - // Source: https://learn.microsoft.com/en-us/dotnet/api/microsoft.office.interop.excel.range.group?view=excel-pia - // - // For numeric grouping: - // - By parameter specifies interval size (e.g., 10 for groups of 10) - // - Start/End parameters define range (null = use field min/max) - // - Periods parameter is IGNORED (only used for date grouping) - - // Get first cell from field.DataRange (items in the field) - singleCell = field.DataRange.Cells[1, 1]; - - // Convert nullable to object - // If start/end are null, use true to let Excel auto-detect min/max - object startValue = start.HasValue ? (object)start.Value : true; - object endValueObj = endValue.HasValue ? (object)endValue.Value : true; - - // Call Group on single cell - // For numeric fields, By specifies the interval size - // Periods is ignored (only used for date grouping) - singleCell.Group( - Start: startValue, - End: endValueObj, - By: intervalSize, - Periods: Type.Missing - ); - - return new PivotFieldResult - { - Success = true, - FieldName = fieldName, - CustomName = field.Caption?.ToString() ?? fieldName, - Area = (PivotFieldArea)field.Orientation, - FilePath = workbookPath, - WorkflowHint = $"Field '{fieldName}' grouped by intervals of {intervalSize}. Excel created numeric range groups." - }; - } - catch (Exception ex) - { - if (logger is not null && logger.IsEnabled(LogLevel.Error)) - { - logger.LogError(ex, "GroupByNumeric failed for field '{FieldName}'", fieldName); - } - return new PivotFieldResult - { - Success = false, - ErrorMessage = $"Failed to group field numerically: {ex.Message}", - FilePath = workbookPath - }; - } - finally - { - ComUtilities.Release(ref singleCell); - ComUtilities.Release(ref field); - } - } - - /// <inheritdoc/> - public PivotFieldResult CreateCalculatedField(dynamic pivot, string fieldName, string formula, string workbookPath, ILogger? logger = null) - { - dynamic? calculatedFields = null; - dynamic? newField = null; - - try - { - // CRITICAL: Refresh PivotTable FIRST to ensure field collection is current - pivot.RefreshTable(); - - // Access CalculatedFields collection - // For regular PivotTables, this collection allows creating custom fields with formulas - // Formula syntax: Use field names directly (e.g., "=Revenue-Cost") - // Excel auto-converts field references to proper format - calculatedFields = pivot.CalculatedFields(); - - // Add calculated field with formula - // UseStandardFormula = true ensures field names are interpreted in US English format - // regardless of user's locale settings - newField = calculatedFields.Add(fieldName, formula, true); - - // Refresh again to populate the new calculated field - pivot.RefreshTable(); - - return new PivotFieldResult - { - Success = true, - FieldName = fieldName, - CustomName = newField.Caption?.ToString() ?? fieldName, - Area = PivotFieldArea.Hidden, // Calculated fields start hidden until added to values - Formula = formula, - FilePath = workbookPath, - WorkflowHint = $"Calculated field '{fieldName}' created with formula: {formula}. " + - "Add to Values area with AddValueField to see results in PivotTable." - }; - } - catch (Exception ex) - { - if (logger is not null && logger.IsEnabled(LogLevel.Error)) - { - logger.LogError(ex, "CreateCalculatedField failed for field '{FieldName}' with formula '{Formula}'", fieldName, formula); - } - return new PivotFieldResult - { - Success = false, - FieldName = fieldName, - Formula = formula, - ErrorMessage = $"Failed to create calculated field: {ex.Message}", - FilePath = workbookPath - }; - } - finally - { - ComUtilities.Release(ref newField); - ComUtilities.Release(ref calculatedFields); - } - } - /// <inheritdoc/> - public OperationResult SetLayout(dynamic pivot, int rowLayout, string workbookPath, ILogger? logger = null) - { - // xlCompactRow=0, xlTabularRow=1, xlOutlineRow=2 - pivot.RowAxisLayout(rowLayout); - - // NOTE: No RefreshTable() needed - Layout is a visual-only property - - if (logger is not null && logger.IsEnabled(LogLevel.Information)) - { - logger.LogInformation("Set PivotTable layout to {LayoutType}", rowLayout); - } - - return new OperationResult - { - Success = true, - FilePath = workbookPath - }; - } - /// <inheritdoc/> - public PivotFieldResult SetSubtotals( - dynamic pivot, - string fieldName, - bool showSubtotals, - string workbookPath, - ILogger? logger = null) - { - dynamic? field = null; - try - { - // Get the field from row fields - dynamic pivotFields = pivot.PivotFields; - field = pivotFields.Item(fieldName); - - // Set subtotals: index 1 = Automatic - // If showSubtotals=true, enable Automatic (which sets all others to false) - // If showSubtotals=false, disable all subtotals - field.Subtotals[1] = showSubtotals; - - // NOTE: No RefreshTable() needed - Subtotals is a visual-only property - - if (logger is not null && logger.IsEnabled(LogLevel.Information)) - { - logger.LogInformation("Set subtotals for field {FieldName} to {ShowSubtotals}", fieldName, showSubtotals); - } - - return new PivotFieldResult - { - Success = true, - FieldName = fieldName, - FilePath = workbookPath, - WorkflowHint = showSubtotals - ? "Subtotals enabled for field. Automatic function selected based on data type." - : "Subtotals disabled for field. Only detail rows visible." - }; - } - finally - { - ComUtilities.Release(ref field); - } - } - /// <inheritdoc/> - public OperationResult SetGrandTotals(dynamic pivot, bool showRowGrandTotals, bool showColumnGrandTotals, string workbookPath, ILogger? logger = null) - { - try - { - // Set row and column grand totals using COM properties - pivot.RowGrand = showRowGrandTotals; - pivot.ColumnGrand = showColumnGrandTotals; - - // NOTE: No RefreshTable() needed - GrandTotals are visual-only properties - - if (logger is not null && logger.IsEnabled(LogLevel.Information)) - { - logger.LogInformation("Set grand totals: Row={RowGrand}, Column={ColumnGrand}", showRowGrandTotals, showColumnGrandTotals); - } - - return new OperationResult - { - Success = true, - FilePath = workbookPath - }; - } - finally - { - // No COM objects to release in this method - } - } -} - - diff --git a/src/ExcelMcp.Core/Commands/PowerQuery/IPowerQueryCommands.cs b/src/ExcelMcp.Core/Commands/PowerQuery/IPowerQueryCommands.cs deleted file mode 100644 index 08e4a144..00000000 --- a/src/ExcelMcp.Core/Commands/PowerQuery/IPowerQueryCommands.cs +++ /dev/null @@ -1,169 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Attributes; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Power Query M code and data loading. -/// -/// TEST-FIRST DEVELOPMENT WORKFLOW (BEST PRACTICE): -/// 1. evaluate - Test M code WITHOUT persisting (catches syntax errors, validates sources, shows data preview) -/// 2. create/update - Store VALIDATED query in workbook -/// 3. refresh/load-to - Load data to destination -/// Skip evaluate only for trivial literal tables. -/// -/// IF CREATE/UPDATE FAILS: Use evaluate to get the actual M engine error message, fix code, retry. -/// -/// DATETIME COLUMNS: Always include Table.TransformColumnTypes() in M code to set column types explicitly. -/// Without explicit types, dates may be stored as numbers and Data Model relationships may fail. -/// -/// DESTINATIONS: 'worksheet' (default), 'data-model' (for DAX), 'both', 'connection-only'. -/// Use 'data-model' to load to Power Pivot, then use datamodel to create DAX measures. -/// -/// TARGET CELL: targetCellAddress places tables without clearing sheet. -/// TIMEOUT: 30 min auto-timeout for refresh and load-to. For quick queries, use timeout=60 or similar. -/// timeout=0 or omitted uses the 30 min default. -/// </summary> -[ServiceCategory("powerquery", "PowerQuery")] -[McpTool("powerquery", Title = "Power Query Operations", Destructive = true, Category = "query", - Description = "Power Query M code and data loading. TEST-FIRST WORKFLOW: 1. evaluate (test M code without persisting) 2. create/update (store validated query) 3. refresh/load-to (load data to destination). IF CREATE FAILS: Use evaluate for detailed M engine error. DATETIME: Always include Table.TransformColumnTypes() for explicit column types. DESTINATIONS: worksheet (default), data-model (for DAX), both, connection-only. M-CODE: Auto-formatted via powerqueryformatter.com. TARGET CELL: targetCellAddress places tables without clearing sheet. TIMEOUT: 30 min auto-timeout for refresh and load-to. For quick queries, use timeout=60. timeout=0 or omitted uses the 30 min default.")] -public interface IPowerQueryCommands -{ - /// <summary> - /// Lists all Power Query queries in the workbook - /// </summary> - [ServiceAction("list")] - PowerQueryListResult List(IExcelBatch batch); - - /// <summary> - /// Views the M code of a Power Query - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="queryName">Name of the query to view</param> - [ServiceAction("view")] - PowerQueryViewResult View(IExcelBatch batch, [RequiredParameter] string queryName); - - /// <summary> - /// Refreshes a Power Query to update its data with error detection using a caller-specified timeout - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="queryName">Name of the query to refresh</param> - /// <param name="timeout">Maximum time to wait for refresh</param> - /// <param name="progress">Optional progress reporter</param> - [ServiceAction("refresh")] - PowerQueryRefreshResult Refresh(IExcelBatch batch, [RequiredParameter] string queryName, TimeSpan timeout, IProgress<ProgressInfo>? progress = null); - - /// <summary> - /// Gets the current load configuration of a Power Query - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="queryName">Name of the query</param> - [ServiceAction("get-load-config")] - PowerQueryLoadConfigResult GetLoadConfig(IExcelBatch batch, [RequiredParameter] string queryName); - - /// <summary> - /// Deletes a Power Query from the workbook - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="queryName">Name of the query to delete</param> - /// <exception cref="InvalidOperationException">Thrown when the Power Query is not found or cannot be deleted</exception> - [ServiceAction("delete")] - OperationResult Delete(IExcelBatch batch, [RequiredParameter] string queryName); - - /// <summary> - /// Creates a new Power Query by importing M code and loading data atomically - /// Replaces multi-step workflow (import + configure + refresh in ONE operation) - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="queryName">Name for the new query</param> - /// <param name="mCode">Raw M code (inline string)</param> - /// <param name="loadMode">Load destination mode</param> - /// <param name="targetSheet">Target worksheet name (required for LoadToTable and LoadToBoth; defaults to query name when omitted)</param> - /// <param name="targetCellAddress">Optional target cell address for worksheet loads (e.g., "B5"). Required when loading to an existing worksheet with other data.</param> - /// <exception cref="InvalidOperationException">Thrown when query cannot be created, M code is invalid, or load operation fails</exception> - OperationResult Create( - IExcelBatch batch, - [RequiredParameter] string queryName, - [RequiredParameter][FileOrValue] string mCode, - [FromString("loadDestination")] PowerQueryLoadMode loadMode = PowerQueryLoadMode.LoadToTable, - string? targetSheet = null, - string? targetCellAddress = null); - - /// <summary> - /// Updates M code. Optionally refreshes loaded data. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="queryName">Name of the query to update</param> - /// <param name="mCode">Raw M code (inline string)</param> - /// <param name="refresh">Whether to refresh data after update (default: true)</param> - /// <exception cref="InvalidOperationException">Thrown when the query is not found, M code is invalid, or refresh fails</exception> - OperationResult Update(IExcelBatch batch, [RequiredParameter] string queryName, [RequiredParameter][FileOrValue] string mCode, bool refresh = true); - - /// <summary> - /// Atomically sets load destination and refreshes data - /// Replaces multi-step workflow (configure + refresh in ONE operation) - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="queryName">Name of the query</param> - /// <param name="loadMode">Load destination mode</param> - /// <param name="targetSheet">Target worksheet name (required for LoadToTable and LoadToBoth)</param> - /// <param name="targetCellAddress">Optional target cell address (e.g., "B5"). Required when loading to an existing worksheet to avoid clearing other content.</param> - /// <exception cref="InvalidOperationException">Thrown when the query is not found, load destination is invalid, or refresh fails</exception> - OperationResult LoadTo( - IExcelBatch batch, - [RequiredParameter] string queryName, - [FromString("loadDestination")] PowerQueryLoadMode loadMode, - string? targetSheet = null, - string? targetCellAddress = null); - - // ValidateSyntaxAsync removed - Excel doesn't validate M code syntax at query creation time. - // Validation only happens during refresh, making syntax-only validation unreliable. - - /// <summary> - /// Refreshes all Power Queries in the workbook. - /// Batch refresh with error tracking. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="timeout">Maximum time to wait for all queries to refresh. Default: 30 minutes. Use a lower value for quick workbooks or higher for very large ones.</param> - /// <param name="progress">Optional progress reporter</param> - /// <exception cref="InvalidOperationException">Thrown when any Power Query fails to refresh</exception> - OperationResult RefreshAll(IExcelBatch batch, TimeSpan timeout = default, IProgress<ProgressInfo>? progress = null); - - /// <summary> - /// Renames a Power Query using trim + case-insensitive uniqueness semantics. - /// - Names are normalized (trimmed) before comparison. - /// - No-op success when normalized names are equal. - /// - Case-only rename attempts COM rename (Excel decides outcome). - /// - No auto-save. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="oldName">Current name of the query</param> - /// <param name="newName">New name for the query</param> - /// <returns>Result with objectType=power-query and normalized names</returns> - [ServiceAction("rename")] - RenameResult Rename(IExcelBatch batch, [RequiredParameter] string oldName, [RequiredParameter] string newName); - - /// <summary> - /// Converts query to connection-only by removing data from all destinations. - /// Removes worksheet ListObjects AND Data Model connections, but keeps the query definition. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="queryName">Name of the query to unload</param> - /// <returns>Operation result</returns> - [ServiceAction("unload")] - OperationResult Unload(IExcelBatch batch, [RequiredParameter] string queryName); - - /// <summary> - /// Evaluates M code and returns the result data without creating a permanent query. - /// Creates a temporary query, executes it, reads the results, then cleans up. - /// Useful for testing M code snippets and getting preview data. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="mCode">M code to evaluate</param> - /// <returns>Result containing evaluated data as columns/rows</returns> - /// <exception cref="InvalidOperationException">Thrown when M code has errors</exception> - [ServiceAction("evaluate")] - PowerQueryEvaluateResult Evaluate(IExcelBatch batch, [RequiredParameter][FileOrValue] string mCode); -} diff --git a/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryCommands.Create.cs b/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryCommands.Create.cs deleted file mode 100644 index c6391f9b..00000000 --- a/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryCommands.Create.cs +++ /dev/null @@ -1,168 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Formatting; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; -using Excel = Microsoft.Office.Interop.Excel; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Power Query Create operation -/// </summary> -public partial class PowerQueryCommands -{ - /// <summary> - /// Creates new Power Query from M code with specified load destination. - /// M code is automatically formatted using the powerqueryformatter.com API before saving. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="queryName">Name for the new query</param> - /// <param name="mCode">Power Query M code</param> - /// <param name="loadMode">Where to load the data (default: LoadToTable)</param> - /// <param name="targetSheet">Target worksheet name (defaults to queryName if not specified)</param> - /// <param name="targetCellAddress">Target cell address (e.g., "A1", "B5")</param> - /// <exception cref="ArgumentException">Thrown when inputs are invalid</exception> - /// <exception cref="InvalidOperationException">Thrown when query already exists or creation fails</exception> - public OperationResult Create( - IExcelBatch batch, - string queryName, - string mCode, - PowerQueryLoadMode loadMode = PowerQueryLoadMode.LoadToTable, - string? targetSheet = null, - string? targetCellAddress = null) - { - // Validate inputs - if (string.IsNullOrWhiteSpace(queryName)) - { - throw new ArgumentException("Query name cannot be empty", nameof(queryName)); - } - - if (string.IsNullOrWhiteSpace(mCode)) - { - throw new ArgumentException("M code cannot be empty", nameof(mCode)); - } - - // Format M code before saving (outside batch.Execute for async operation) - // Formatting is done synchronously to maintain method signature compatibility - // Falls back to original if formatting fails - string formattedMCode = MCodeFormatter.FormatAsync(mCode).GetAwaiter().GetResult(); - - // Resolve target sheet name (default to query name) - if (loadMode == PowerQueryLoadMode.LoadToTable || loadMode == PowerQueryLoadMode.LoadToBoth) - { - targetSheet ??= queryName; - } - - // Resolve target cell address (default to A1) - targetCellAddress ??= "A1"; - - return batch.Execute((ctx, ct) => - { - Excel.Queries? queries = null; - Excel.WorkbookQuery? query = null; - - try - { - queries = ctx.Book.Queries; - - // Check if query already exists - Excel.WorkbookQuery? existingQuery = FindQueryByName(queries, queryName); - if (existingQuery != null) - { - ComUtilities.Release(ref existingQuery); - throw new InvalidOperationException($"Query '{queryName}' already exists"); - } - - // Step 1: Create the query (always creates in ConnectionOnly mode initially) - // Uses formatted M code for better readability - query = queries.Add(queryName, formattedMCode); - - // Step 2: Apply load destination based on mode - var result = new PowerQueryCreateResult - { - FilePath = batch.WorkbookPath, - QueryName = queryName, - LoadDestination = loadMode, - WorksheetName = targetSheet, - TargetCellAddress = targetCellAddress, - QueryCreated = true - }; - - switch (loadMode) - { - case PowerQueryLoadMode.ConnectionOnly: - // Query created, no data loading needed - result.DataLoaded = false; - result.RowsLoaded = 0; - result.TargetCellAddress = null; - result.Success = true; - break; - - case PowerQueryLoadMode.LoadToTable: - LoadQueryToWorksheet(ctx.Book, queryName, targetSheet!, targetCellAddress!, result); - break; - - case PowerQueryLoadMode.LoadToDataModel: - LoadQueryToDataModel(ctx.Book, queryName, result); - break; - - case PowerQueryLoadMode.LoadToBoth: - // For LoadToBoth, create TWO separate properly-named connections: - // 1. Worksheet connection: "Query - {name}" (created by LoadQueryToWorksheet) - // 2. Data Model connection: "Query - {name} (Data Model)" (with suffix to avoid conflict) - LoadQueryToWorksheet(ctx.Book, queryName, targetSheet!, targetCellAddress!, result); - LoadQueryToDataModel(ctx.Book, queryName, result, " (Data Model)"); - break; - } - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref query); - ComUtilities.Release(ref queries); - } - }, cancellationToken: default); - } - - /// <summary> - /// Finds a query by name in the queries collection. - /// Returns null if not found. - /// </summary> - private static Excel.WorkbookQuery? FindQueryByName(Excel.Queries queriesCollection, string queryName) - { - try - { - int count = queriesCollection.Count; - for (int i = 1; i <= count; i++) - { - Excel.WorkbookQuery? query = null; - try - { - query = queriesCollection.Item(i); - string name = query.Name ?? ""; - - if (name.Equals(queryName, StringComparison.OrdinalIgnoreCase)) - { - return query; // Caller must release - } - } - finally - { - if (query != null) - { - ComUtilities.Release(ref query); - } - } - } - } - catch (System.Runtime.InteropServices.COMException) - { - // Query not found or error accessing collection - } - - return null; - } -} - - diff --git a/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryCommands.Evaluate.cs b/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryCommands.Evaluate.cs deleted file mode 100644 index 4dc57763..00000000 --- a/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryCommands.Evaluate.cs +++ /dev/null @@ -1,290 +0,0 @@ -using System.Runtime.InteropServices; -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; -using Excel = Microsoft.Office.Interop.Excel; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Power Query evaluate operation - executes M code and returns results without creating a permanent query -/// </summary> -public partial class PowerQueryCommands -{ - /// <inheritdoc /> - public PowerQueryEvaluateResult Evaluate(IExcelBatch batch, string mCode) - { - var result = new PowerQueryEvaluateResult - { - FilePath = batch.WorkbookPath, - MCode = mCode - }; - - // Validate M code - if (string.IsNullOrWhiteSpace(mCode)) - { - throw new ArgumentException("M code is required for evaluate action", nameof(mCode)); - } - - // Generate unique temporary names - var uniqueId = Guid.NewGuid().ToString("N")[..8]; - var tempQueryName = $"__pq_eval_{uniqueId}"; - var tempSheetName = $"__pq_eval_{uniqueId}"; - - return batch.Execute((ctx, ct) => - { - Excel.Queries? queriesCollection = null; - Excel.WorkbookQuery? query = null; - dynamic? worksheets = null; - dynamic? tempSheet = null; - dynamic? listObjects = null; - dynamic? listObject = null; - dynamic? queryTable = null; - dynamic? range = null; - dynamic? usedRange = null; - - try - { - // STEP 1: Create temporary query with the M code - queriesCollection = ctx.Book.Queries; - query = queriesCollection.Add(tempQueryName, mCode); - - // STEP 2: Create temporary worksheet and load data to it - worksheets = ctx.Book.Worksheets; - tempSheet = worksheets.Add(); - tempSheet.Name = tempSheetName; - - // STEP 3: Load query to worksheet using QueryTable - // Connection string for Power Query - must include Extended Properties - string connectionString = $"OLEDB;Provider=Microsoft.Mashup.OleDb.1;Data Source=$Workbook$;Location={tempQueryName};Extended Properties=\"\""; - range = tempSheet.Range["A1"]; - - listObjects = tempSheet.ListObjects; - listObject = listObjects.Add( - 0, // SourceType: 0 = xlSrcExternal - connectionString, // Source: connection string - Type.Missing, // LinkSource - 1, // XlListObjectHasHeaders: xlYes = 1 - range // Destination: starting cell - ); - - // Get the QueryTable to refresh (this executes the M code) - queryTable = listObject.QueryTable; - - // Configure QueryTable to select from the query - queryTable.CommandType = 2; // xlCmdSql - queryTable.CommandText = $"SELECT * FROM [{tempQueryName}]"; - queryTable.BackgroundQuery = false; // Synchronous - - // STEP 4: Refresh to execute the M code (errors will throw via QueryTable.Refresh) - // This is the key step - if M code has errors, this will throw! - queryTable.Refresh(false); // false = synchronous - - // STEP 5: Read the results from the worksheet - // Get the data range from the ListObject - dynamic? dataBodyRange = null; - dynamic? headerRowRange = null; - try - { - // Get column names from header row - headerRowRange = listObject.HeaderRowRange; - if (headerRowRange != null) - { - dynamic? headerValues = headerRowRange.Value2; - if (headerValues != null) - { - if (headerValues is object[,] headers2D) - { - for (int col = 1; col <= headers2D.GetLength(1); col++) - { - result.Columns.Add(headers2D[1, col]?.ToString() ?? $"Column{col}"); - } - } - else if (headerValues is object[] headers1D) - { - for (int i = 0; i < headers1D.Length; i++) - { - result.Columns.Add(headers1D[i]?.ToString() ?? $"Column{i + 1}"); - } - } - else - { - // Single cell - result.Columns.Add(headerValues?.ToString() ?? "Column1"); - } - } - } - result.ColumnCount = result.Columns.Count; - - // Get data rows - dataBodyRange = listObject.DataBodyRange; - if (dataBodyRange != null) - { - dynamic? dataValues = dataBodyRange.Value2; - if (dataValues != null) - { - if (dataValues is object[,] data2D) - { - int rowCount = data2D.GetLength(0); - int colCount = data2D.GetLength(1); - - for (int row = 1; row <= rowCount; row++) - { - var rowData = new List<object?>(); - for (int col = 1; col <= colCount; col++) - { - rowData.Add(ConvertCellValue(data2D[row, col])); - } - result.Rows.Add(rowData); - } - } - else if (dataValues is object[] data1D) - { - // Single row - var rowData = new List<object?>(); - foreach (var val in data1D) - { - rowData.Add(ConvertCellValue(val)); - } - result.Rows.Add(rowData); - } - else - { - // Single cell - result.Rows.Add([ConvertCellValue(dataValues)]); - } - } - } - result.RowCount = result.Rows.Count; - } - finally - { - ComUtilities.Release(ref headerRowRange); - ComUtilities.Release(ref dataBodyRange); - } - - result.Success = true; - } - finally - { - // STEP 6: Cleanup - delete temporary objects - // Order matters: delete table, sheet, query, connections - - // Delete the ListObject (table) - try - { - if (listObject != null) - { - listObject.Delete(); - } - } - catch (COMException) { /* ignore cleanup errors */ } - - ComUtilities.Release(ref queryTable); - ComUtilities.Release(ref listObject); - ComUtilities.Release(ref listObjects); - ComUtilities.Release(ref range); - ComUtilities.Release(ref usedRange); - - // Delete the temporary worksheet - try - { - if (tempSheet != null) - { - // Suppress alerts to avoid "Are you sure you want to delete?" prompt - dynamic? app = null; - try - { - app = ctx.Book.Application; - bool originalAlerts = app.DisplayAlerts; - app.DisplayAlerts = false; - try - { - tempSheet.Delete(); - } - finally - { - app.DisplayAlerts = originalAlerts; - } - } - finally - { - ComUtilities.Release(ref app); - } - } - } - catch (COMException) { /* ignore cleanup errors */ } - - ComUtilities.Release(ref tempSheet); - ComUtilities.Release(ref worksheets); - - // Delete the temporary query - try - { - if (query != null) - { - query.Delete(); - } - } - catch (COMException) { /* ignore cleanup errors */ } - - ComUtilities.Release(ref query); - ComUtilities.Release(ref queriesCollection); - - // Clean up any lingering connections - dynamic? connections = null; - try - { - connections = ctx.Book.Connections; - for (int i = connections.Count; i >= 1; i--) - { - dynamic? conn = null; - try - { - conn = connections.Item(i); - string connName = conn.Name?.ToString() ?? ""; - if (connName.Contains(tempQueryName)) - { - conn.Delete(); - } - } - catch (COMException) { /* ignore cleanup errors */ } - finally - { - ComUtilities.Release(ref conn); - } - } - } - catch (COMException) { /* ignore cleanup errors */ } - finally - { - ComUtilities.Release(ref connections); - } - } - - return result; - }); - } - - /// <summary> - /// Converts Excel cell values to JSON-friendly types - /// </summary> - private static object? ConvertCellValue(object? value) - { - if (value == null || value == DBNull.Value) - return null; - - return value switch - { - DateTime dt => dt.ToString("O"), // ISO 8601 format - double d => d, - int i => i, - long l => l, - bool b => b, - string s => s, - _ => value.ToString() - }; - } -} - - diff --git a/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryCommands.Helpers.cs b/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryCommands.Helpers.cs deleted file mode 100644 index f8c0192f..00000000 --- a/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryCommands.Helpers.cs +++ /dev/null @@ -1,350 +0,0 @@ -using System.Runtime.InteropServices; -using Microsoft.CSharp.RuntimeBinder; -using Sbroenne.ExcelMcp.ComInterop; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Power Query helper methods (internal utilities) -/// </summary> -public partial class PowerQueryCommands -{ - /// <summary> - /// Core connection refresh logic - finds and refreshes the connection for a query. - /// - /// Error propagation depends on connection type: - /// - Worksheet queries (InModel=false): Errors thrown via QueryTable.Refresh(false) - /// - Data Model queries (InModel=true): Errors thrown via Connection.Refresh() - /// - /// Strategy order ensures we use the appropriate method for each connection type: - /// 1. Try QueryTable.Refresh() first (handles worksheet queries) - /// 2. Fall back to Connection.Refresh() (handles Data Model queries) - /// </summary> - /// <returns>True if refresh was executed, false if no connection or table found</returns> - /// <exception cref="Exception">Thrown if Power Query has formula errors</exception> - private static bool RefreshConnectionByQueryName(dynamic workbook, string queryName, CancellationToken cancellationToken) - { - // Strategy 1: Find and refresh QueryTable directly on worksheet - // For worksheet queries (InModel=false), errors are thrown by QueryTable.Refresh() - if (RefreshQueryTableByName(workbook, queryName)) - { - return true; - } - - // Strategy 2: Find connection by name patterns and refresh - // For Data Model queries (InModel=true), errors are thrown by Connection.Refresh() - dynamic? targetConnection = null; - dynamic? connections = null; - try - { - connections = workbook.Connections; - for (int i = 1; i <= connections.Count; i++) - { - dynamic? conn = null; - try - { - conn = connections.Item(i); - string connName = conn.Name?.ToString() ?? ""; - if (connName.Equals(queryName, StringComparison.OrdinalIgnoreCase) || - connName.Equals($"Query - {queryName}", StringComparison.OrdinalIgnoreCase)) - { - targetConnection = conn; - conn = null; // Don't release - we're using it - break; - } - } - finally - { - ComUtilities.Release(ref conn); - } - } - } - finally - { - ComUtilities.Release(ref connections); - } - - if (targetConnection != null) - { - try - { - RefreshWorkbookConnection(targetConnection, cancellationToken); - return true; - } - finally - { - ComUtilities.Release(ref targetConnection); - } - } - - return false; - } - - /// <summary> - /// Finds and refreshes a QueryTable by searching ListObjects on all worksheets. - /// Matches by query name in the QueryTable's connection string (Location=queryName). - /// </summary> - /// <returns>True if QueryTable was found and refreshed</returns> - /// <exception cref="Exception">Thrown if Power Query has formula errors</exception> - private static bool RefreshQueryTableByName(dynamic workbook, string queryName) - { - dynamic? worksheets = null; - try - { - worksheets = workbook.Worksheets; - - for (int ws = 1; ws <= worksheets.Count; ws++) - { - dynamic? worksheet = null; - dynamic? listObjects = null; - try - { - worksheet = worksheets.Item(ws); - listObjects = worksheet.ListObjects; - - for (int lo = 1; lo <= listObjects.Count; lo++) - { - dynamic? listObject = null; - dynamic? queryTable = null; - try - { - listObject = listObjects.Item(lo); - - // Try to get QueryTable - not all ListObjects have one - try - { - queryTable = listObject.QueryTable; - } - catch (System.Runtime.InteropServices.COMException) - { - // ListObject doesn't have a QueryTable - expected for user-created tables - continue; - } - - if (queryTable == null) - { - continue; - } - - // Check if this QueryTable is for our query by examining connection string - // Format: "OLEDB;...;Location=QueryName;..." - string? connection = queryTable.Connection?.ToString(); - if (connection != null && - connection.Contains($"Location={queryName}", StringComparison.OrdinalIgnoreCase)) - { - // Keep synchronous refresh semantics for worksheet queries. - // QueryTable.Refresh(false) is the only reliable path that propagates - // Power Query formula errors for worksheet-loaded queries. - OleMessageFilter.EnterLongOperation(); - try - { - queryTable.Refresh(false); - } - finally - { - OleMessageFilter.ExitLongOperation(); - } - - return true; - } - } - finally - { - ComUtilities.Release(ref queryTable); - ComUtilities.Release(ref listObject); - } - } - } - finally - { - ComUtilities.Release(ref listObjects); - ComUtilities.Release(ref worksheet); - } - } - } - finally - { - ComUtilities.Release(ref worksheets); - } - - return false; - } - - private static void RefreshWorkbookConnection(dynamic connection, CancellationToken cancellationToken) - { - dynamic? oleDbConnection = null; - bool originalBackgroundQuery = false; - bool canRestoreBackgroundQuery = false; - bool supportsRefreshing = false; - - try - { - try - { - oleDbConnection = connection.OLEDBConnection; - if (oleDbConnection != null) - { - originalBackgroundQuery = oleDbConnection.BackgroundQuery; - canRestoreBackgroundQuery = true; - - // CRITICAL: Force BackgroundQuery = false to ensure synchronous refresh. - // - // With BackgroundQuery = true (async), connection.Refresh() returns immediately - // while Excel processes the query in a background thread. We then poll - // connection.Refreshing with Thread.Sleep(200). On STA threads with the - // OleMessageFilter registered, COM events from Excel during background refresh - // (SheetChange, Calculate, Data Model callbacks) cause Thread.Sleep to return - // via MsgWaitForMultipleObjectsEx — turning the polling loop into a 100% CPU - // spin lasting the full duration of the refresh (seconds to minutes). - // - // With BackgroundQuery = false (synchronous), connection.Refresh() blocks the - // STA thread until the refresh completes. When it returns, connection.Refreshing - // is already false, so WaitForRefreshCompletion exits in 0 iterations. Zero spin. - oleDbConnection.BackgroundQuery = false; - } - } - catch (COMException) - { - // Not an OLEDB connection or provider doesn't support BackgroundQuery. - } - catch (RuntimeBinderException) - { - // Sub-connection doesn't expose BackgroundQuery via dynamic binding. - } - - // Enter long operation mode: MessagePending returns WAITDEFPROCESS to dispatch - // to HandleInComingCall, which rejects with SERVERCALL_RETRYLATER. - // This triggers the caller's RetryRejectedCall backoff instead of either: - // - WAITNOPROCESS rejection storm (88% CPU) or - // - WAITDEFPROCESS + EnsureScanDefinedEvents spin (97% CPU) - OleMessageFilter.EnterLongOperation(); - try - { - connection.Refresh(); - } - finally - { - OleMessageFilter.ExitLongOperation(); - } - - try - { - _ = connection.Refreshing; - supportsRefreshing = true; - } - catch (RuntimeBinderException) - { - supportsRefreshing = false; - } - catch (COMException) - { - supportsRefreshing = false; - } - - if (supportsRefreshing) - { - WaitForRefreshCompletion( - () => - { - try - { - return connection.Refreshing; - } - catch (RuntimeBinderException) - { - return false; - } - catch (COMException) - { - return false; - } - }, - () => - { - try - { - connection.CancelRefresh(); - } - catch (RuntimeBinderException) - { - // Ignore inability to cancel for unsupported providers. - } - catch (COMException) - { - // Ignore inability to cancel for unsupported providers. - } - }, - cancellationToken); - } - } - finally - { - if (canRestoreBackgroundQuery && oleDbConnection != null) - { - try - { - oleDbConnection.BackgroundQuery = originalBackgroundQuery; - } - catch (COMException) - { - // Ignore inability to restore provider-specific setting. - } - } - - ComUtilities.Release(ref oleDbConnection); - } - } - - private static void WaitForRefreshCompletion( - Func<bool> isRefreshing, - Action cancelRefresh, - CancellationToken cancellationToken) - { - // CRITICAL: Rate-limit the isRefreshing() COM call to every 200ms of *real* elapsed time. - // - // On STA threads with OleMessageFilter registered, COM events from Excel during refresh - // (SheetChange, Calculate, Data Model callbacks) wake Thread.Sleep immediately via - // MsgWaitForMultipleObjectsEx (CoWaitForMultipleHandles). Without rate-limiting, - // isRefreshing() (a cross-process COM property access, ~200-500μs) runs thousands of - // times/second → 100% CPU spin. - // - // Use KernelSleep (Win32 Sleep via P/Invoke) instead of Thread.Sleep: - // Thread.Sleep on STA threads uses CoWaitForMultipleHandles which pumps the COM message - // queue and wakes early on every incoming COM event (Data Model row-write callbacks from - // MashupHost.exe, SheetChange, etc.). During large PQ refreshes this causes CPU spin - // even with the Stopwatch guard. Win32 Sleep() is a bare NtDelayExecution call with no - // COM pumping — the thread genuinely sleeps the full 200ms per interval. - // Safety: refresh completion is driven by Excel's own internals (MashupHost → Excel STA). - // connection.Refreshing flips to false in Excel's process without requiring our STA to - // service any callbacks. The Stopwatch guard is kept as defensive belt-and-suspenders. - const int CheckIntervalMs = 200; - var sw = System.Diagnostics.Stopwatch.StartNew(); - try - { - // Initial check: if already done, skip the wait entirely. - if (!isRefreshing()) - return; - - while (true) - { - cancellationToken.ThrowIfCancellationRequested(); - // Sleep without pumping the STA COM queue. Win32 Sleep() does not wake early - // on COM events, so the Stopwatch guard below is belt-and-suspenders only. - ComUtilities.KernelSleep(CheckIntervalMs); - // Guard: loop back without calling isRefreshing() if sleep returned early. - if (sw.Elapsed.TotalMilliseconds < CheckIntervalMs) - continue; - sw.Restart(); - if (!isRefreshing()) - break; - } - } - catch (OperationCanceledException) - { - cancelRefresh(); - throw; - } - } -} - - diff --git a/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryCommands.Lifecycle.cs b/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryCommands.Lifecycle.cs deleted file mode 100644 index fd63e774..00000000 --- a/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryCommands.Lifecycle.cs +++ /dev/null @@ -1,789 +0,0 @@ -using System.Runtime.InteropServices; -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; -using Excel = Microsoft.Office.Interop.Excel; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Power Query lifecycle operations (List, View, Import, Export, Update, Delete) -/// </summary> -public partial class PowerQueryCommands -{ - /// <inheritdoc /> - public PowerQueryListResult List(IExcelBatch batch) - { - var result = new PowerQueryListResult { FilePath = batch.WorkbookPath }; - - return batch.Execute((ctx, ct) => - { - Excel.Queries? queriesCollection = null; - try - { - queriesCollection = ctx.Book.Queries; - int count = queriesCollection.Count; - - for (int i = 1; i <= count; i++) - { - Excel.WorkbookQuery? query = null; - try - { - query = queriesCollection.Item(i); - string name = query.Name ?? $"Query{i}"; - - // Try to read formula - some queries may not have accessible formulas - string formula = ""; - try - { - formula = query.Formula?.ToString() ?? ""; - } - catch (COMException) - { - // Formula property not accessible (e.g., corrupted query, permission issue) - // Don't fail the entire List operation - just mark this query - formula = ""; - } - - string preview = formula.Length > 80 ? formula[..77] + "..." : formula; - if (string.IsNullOrEmpty(formula)) - { - preview = "(formula not accessible)"; - } - - // Check if loaded to table (ListObject) - same pattern as GetLoadConfig - bool isConnectionOnly = true; - dynamic? worksheets = null; - try - { - worksheets = ctx.Book.Worksheets; - for (int ws = 1; ws <= worksheets.Count; ws++) - { - dynamic? worksheet = null; - dynamic? listObjects = null; - try - { - worksheet = worksheets.Item(ws); - listObjects = worksheet.ListObjects; - - for (int lo = 1; lo <= listObjects.Count; lo++) - { - dynamic? listObject = null; - dynamic? queryTable = null; - dynamic? wbConn = null; - dynamic? oledbConn = null; - try - { - listObject = listObjects.Item(lo); - - // QueryTable property may throw 0x800A03EC if ListObject doesn't have a valid QueryTable - // This is normal - not all ListObjects have QueryTables (e.g., manually created tables) - try - { - queryTable = listObject.QueryTable; - } - catch (COMException ex) - when (ex.HResult == unchecked((int)0x800A03EC)) - { - // ListObject doesn't have QueryTable - skip it - continue; - } - - if (queryTable == null) continue; - - wbConn = queryTable.WorkbookConnection; - if (wbConn == null) continue; - - // Non-OLEDB connection types (Type=7, Type=8) throw COMException - try { oledbConn = wbConn.OLEDBConnection; } - catch (System.Runtime.InteropServices.COMException) { continue; } - if (oledbConn == null) continue; - - string connString = oledbConn.Connection?.ToString() ?? ""; - bool isMashup = connString.Contains("Provider=Microsoft.Mashup.OleDb.1", StringComparison.OrdinalIgnoreCase); - bool locationMatches = connString.Contains($"Location={name}", StringComparison.OrdinalIgnoreCase); - - if (isMashup && locationMatches) - { - isConnectionOnly = false; - ComUtilities.Release(ref oledbConn!); - ComUtilities.Release(ref wbConn!); - ComUtilities.Release(ref queryTable!); - ComUtilities.Release(ref listObject!); - break; - } - } - finally - { - ComUtilities.Release(ref oledbConn!); - ComUtilities.Release(ref wbConn!); - ComUtilities.Release(ref queryTable!); - ComUtilities.Release(ref listObject!); - } - } - } - finally - { - ComUtilities.Release(ref listObjects!); - ComUtilities.Release(ref worksheet!); - } - if (!isConnectionOnly) break; - } - } - finally - { - ComUtilities.Release(ref worksheets!); - } - - // Also check for Data Model connections - // A query loaded ONLY to Data Model has no ListObjects but has a connection - if (isConnectionOnly) - { - dynamic? connections = null; - try - { - connections = ctx.Book.Connections; - for (int c = 1; c <= connections.Count; c++) - { - dynamic? conn = null; - try - { - conn = connections.Item(c); - string connName = conn.Name?.ToString() ?? ""; - - // Check if this is a Data Model connection for our query - // Patterns: - // - "Query - {queryName}" (worksheet connection) - // - "Query - {queryName} (Data Model)" (Data Model connection) - // - "Query - {queryName} - suffix" (legacy pattern) - if (connName.Equals($"Query - {name}", StringComparison.OrdinalIgnoreCase) || - connName.StartsWith($"Query - {name} -", StringComparison.OrdinalIgnoreCase) || - connName.StartsWith($"Query - {name} (", StringComparison.OrdinalIgnoreCase)) - { - // Has Data Model connection - NOT connection-only - isConnectionOnly = false; - break; - } - } - finally - { - ComUtilities.Release(ref conn); - } - } - } - finally - { - ComUtilities.Release(ref connections); - } - } - - result.Queries.Add(new PowerQueryInfo - { - Name = name, - Formula = formula, - FormulaPreview = preview, - IsConnectionOnly = isConnectionOnly - }); - } - catch (COMException) - { - // Skip query if COM error occurs during processing - // This allows listing to continue for remaining queries - // COM exceptions occur for corrupted queries or access issues - continue; - } - finally - { - ComUtilities.Release(ref query); - } - } - - result.Success = true; - return result; - } - finally - { - ComUtilities.Release(ref queriesCollection); - } - }); - } - - // View method moved to PowerQueryCommands.View.cs (standalone implementation) - - /// <inheritdoc /> - public PowerQueryLoadConfigResult GetLoadConfig(IExcelBatch batch, string queryName) - { - var result = new PowerQueryLoadConfigResult - { - FilePath = batch.WorkbookPath, - QueryName = queryName - }; - - // Validate query name - if (!ValidateQueryName(queryName, out string? validationError)) - { - throw new ArgumentException(validationError, nameof(queryName)); - } - - return batch.Execute((ctx, ct) => - { - Excel.WorkbookQuery? query = null; - dynamic? worksheets = null; - dynamic? connections = null; - try - { - query = ComUtilities.FindQuery(ctx.Book, queryName); - if (query == null) - { - throw new InvalidOperationException($"Query '{queryName}' not found."); - } - - // Check for ListObjects first (Power Query loaded to table creates a ListObject) - bool hasTableConnection = false; - bool hasDataModelConnection = false; - string? targetSheet = null; - - worksheets = ctx.Book.Worksheets; - for (int ws = 1; ws <= worksheets.Count; ws++) - { - dynamic? worksheet = null; - dynamic? listObjects = null; - try - { - worksheet = worksheets.Item(ws); - listObjects = worksheet.ListObjects; - - for (int lo = 1; lo <= listObjects.Count; lo++) - { - dynamic? listObject = null; - dynamic? queryTable = null; - dynamic? wbConn = null; - dynamic? oledbConn = null; - try - { - listObject = listObjects.Item(lo); - - // QueryTable property throws 0x800A03EC for regular tables - // (i.e., tables not backed by an external data query). Skip them. - try - { - queryTable = listObject.QueryTable; - } - catch (System.Runtime.InteropServices.COMException ex) - when (ex.HResult == unchecked((int)0x800A03EC)) - { - continue; - } - - if (queryTable == null) continue; - - wbConn = queryTable.WorkbookConnection; - if (wbConn == null) continue; - - // Non-OLEDB connection types (Type=7, Type=8) throw COMException - try { oledbConn = wbConn.OLEDBConnection; } - catch (System.Runtime.InteropServices.COMException) { continue; } - if (oledbConn == null) continue; - - string connString = oledbConn.Connection?.ToString() ?? ""; - bool isMashup = connString.Contains("Provider=Microsoft.Mashup.OleDb.1", StringComparison.OrdinalIgnoreCase); - bool locationMatches = connString.Contains($"Location={queryName}", StringComparison.OrdinalIgnoreCase); - - // Also check CommandText as fallback - string commandText = ""; - try - { - if (queryTable.CommandText is object[] arr && arr.Length > 0) - commandText = arr[0]?.ToString() ?? ""; - else - commandText = queryTable.CommandText?.ToString() ?? ""; - } - catch (COMException) - { - // CommandText property may not be accessible for certain QueryTable types - } - - bool cmdMatches = commandText.Contains($"[{queryName}]", StringComparison.OrdinalIgnoreCase); - - if (isMashup && (locationMatches || cmdMatches)) - { - hasTableConnection = true; - targetSheet = worksheet.Name; - ComUtilities.Release(ref oledbConn!); - ComUtilities.Release(ref wbConn!); - ComUtilities.Release(ref queryTable!); - ComUtilities.Release(ref listObject!); - break; - } - } - finally - { - ComUtilities.Release(ref oledbConn!); - ComUtilities.Release(ref wbConn!); - ComUtilities.Release(ref queryTable!); - ComUtilities.Release(ref listObject!); - } - } - } - finally - { - ComUtilities.Release(ref listObjects!); - ComUtilities.Release(ref worksheet!); - } - if (hasTableConnection) break; - } - - // Check connections for Data Model membership using InModel property - connections = ctx.Book.Connections; - for (int i = 1; i <= connections.Count; i++) - { - dynamic? conn = null; - dynamic? oledbConn = null; - try - { - conn = connections.Item(i); - string connName = conn.Name?.ToString() ?? ""; - - // Check if this connection is related to our query - // Patterns: - // - "{queryName}" (exact match) - // - "Query - {queryName}" (worksheet connection) - // - "Query - {queryName} (Data Model)" (Data Model connection) - // - "Query - {queryName} - suffix" (legacy pattern) - bool isQueryConnection = connName.Equals(queryName, StringComparison.OrdinalIgnoreCase) || - connName.Equals($"Query - {queryName}", StringComparison.OrdinalIgnoreCase) || - connName.StartsWith($"Query - {queryName} -", StringComparison.OrdinalIgnoreCase) || - connName.StartsWith($"Query - {queryName} (", StringComparison.OrdinalIgnoreCase); - - // Also check connection string for Power Query pattern - if (!isQueryConnection) - { - try - { - oledbConn = conn.OLEDBConnection; - if (oledbConn != null) - { - string connString = oledbConn.Connection?.ToString() ?? ""; - bool isPowerQuery = connString.Contains("Provider=Microsoft.Mashup.OleDb.1", StringComparison.OrdinalIgnoreCase); - bool matchesQuery = connString.Contains($"Location={queryName}", StringComparison.OrdinalIgnoreCase); - isQueryConnection = isPowerQuery && matchesQuery; - } - } - catch (Exception ex) when (ex is COMException or System.Reflection.TargetInvocationException) - { - // Connection type doesn't have OLEDBConnection property - skip - } - } - - if (isQueryConnection) - { - result.HasConnection = true; - - // Check InModel property to detect Data Model connections - try - { - bool inModel = conn.InModel; - if (inModel) - { - hasDataModelConnection = true; - } - } - catch (Exception ex) when (ex is COMException or System.Reflection.TargetInvocationException) - { - // InModel property not available for this connection type - } - } - } - finally - { - ComUtilities.Release(ref oledbConn!); - ComUtilities.Release(ref conn); - } - } - - // Determine load mode - if (hasTableConnection && hasDataModelConnection) - { - result.LoadMode = PowerQueryLoadMode.LoadToBoth; - } - else if (hasTableConnection) - { - result.LoadMode = PowerQueryLoadMode.LoadToTable; - } - else if (hasDataModelConnection) - { - result.LoadMode = PowerQueryLoadMode.LoadToDataModel; - } - else - { - result.LoadMode = PowerQueryLoadMode.ConnectionOnly; - } - - result.TargetSheet = targetSheet; - result.IsLoadedToDataModel = hasDataModelConnection; - result.Success = true; - return result; - } - finally - { - ComUtilities.Release(ref connections); - ComUtilities.Release(ref worksheets); - ComUtilities.Release(ref query); - } - }); - } - - /// <inheritdoc /> - public OperationResult Delete(IExcelBatch batch, string queryName) - { - // Validate query name - if (!ValidateQueryName(queryName, out string? validationError)) - { - throw new ArgumentException(validationError, nameof(queryName)); - } - - return batch.Execute((ctx, ct) => - { - Excel.WorkbookQuery? query = null; - Excel.Queries? queriesCollection = null; - dynamic? worksheets = null; - - try - { - query = ComUtilities.FindQuery(ctx.Book, queryName); - if (query == null) - { - throw new InvalidOperationException($"Query '{queryName}' not found."); - } - - // STEP 1: Clean up any ListObjects (tables) that reference this query - // When a query is loaded to a worksheet, Excel creates a ListObject with QueryTable - // Delete must remove these to prevent orphaned tables - worksheets = ctx.Book.Worksheets; - int worksheetCount = worksheets.Count; - - for (int i = 1; i <= worksheetCount; i++) - { - dynamic? sheet = null; - dynamic? listObjects = null; - - try - { - sheet = worksheets.Item(i); - listObjects = sheet.ListObjects; - int tableCount = listObjects.Count; - - // Iterate backwards to safely delete while iterating - for (int j = tableCount; j >= 1; j--) - { - dynamic? table = null; - dynamic? queryTable = null; - dynamic? oleDbConnection = null; - - try - { - table = listObjects.Item(j); - - // Check if this table has a QueryTable with our query - try - { - queryTable = table.QueryTable; - if (queryTable != null) - { - oleDbConnection = queryTable.WorkbookConnection?.OLEDBConnection; - if (oleDbConnection != null) - { - string? connString = oleDbConnection.Connection?.ToString() ?? ""; - // Check if connection string references our query - // Format: "OLEDB;Provider=Microsoft.Mashup.OleDb.1;Data Source=$Workbook$;Location=QueryName" - if (connString.Contains("Microsoft.Mashup.OleDb") && - connString.Contains($"Location={queryName}")) - { - // This table is associated with our query - delete it - table.Delete(); - } - } - } - } - catch (Exception ex) when (ex is COMException or System.Reflection.TargetInvocationException) - { - // Table doesn't have QueryTable property - skip - } - } - finally - { - ComUtilities.Release(ref oleDbConnection); - ComUtilities.Release(ref queryTable); - ComUtilities.Release(ref table); - } - } - } - finally - { - ComUtilities.Release(ref listObjects); - ComUtilities.Release(ref sheet); - } - } - - // STEP 2: Remove Data Model connections - // Data Model connections follow pattern: "Query - {queryName}" or "Query - {queryName} - suffix" - dynamic? connections = null; - try - { - connections = ctx.Book.Connections; - var connectionsToDelete = new List<string>(); - - for (int c = 1; c <= connections.Count; c++) - { - dynamic? conn = null; - try - { - conn = connections.Item(c); - string connName = conn.Name?.ToString() ?? ""; - - // Check if this is a connection for our query - // Patterns: - // - "Query - {queryName}" (worksheet connection) - // - "Query - {queryName} (Data Model)" (Data Model connection) - // - "Query - {queryName} - suffix" (legacy pattern) - if (connName.Equals($"Query - {queryName}", StringComparison.OrdinalIgnoreCase) || - connName.StartsWith($"Query - {queryName} -", StringComparison.OrdinalIgnoreCase) || - connName.StartsWith($"Query - {queryName} (", StringComparison.OrdinalIgnoreCase)) - { - connectionsToDelete.Add(connName); - } - } - finally - { - ComUtilities.Release(ref conn); - } - } - - // Delete connections - foreach (var connName in connectionsToDelete) - { - dynamic? connToDelete = null; - try - { - connToDelete = connections.Item(connName); - connToDelete.Delete(); - } - catch (COMException) - { - // Connection may have already been deleted - safe to ignore - } - finally - { - ComUtilities.Release(ref connToDelete); - } - } - } - finally - { - ComUtilities.Release(ref connections); - } - - queriesCollection = ctx.Book.Queries; - queriesCollection.Item(queryName).Delete(); - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref worksheets); - ComUtilities.Release(ref queriesCollection); - ComUtilities.Release(ref query); - } - }); - } - - - /// <summary> - /// Converts query to connection-only (removes data load) - /// Uses ListObjects pattern (matches Delete cleanup logic) - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="queryName">Name of the query</param> - /// <returns>Operation result</returns> - public OperationResult Unload(IExcelBatch batch, string queryName) - { - var result = new OperationResult - { - FilePath = batch.WorkbookPath, - Action = "unload" - }; - - // Validate query name - if (!ValidateQueryName(queryName, out string? validationError)) - { - throw new ArgumentException(validationError, nameof(queryName)); - } - - return batch.Execute((ctx, ct) => - { - Excel.WorkbookQuery? query = null; - - try - { - query = ComUtilities.FindQuery(ctx.Book, queryName); - if (query == null) - { - throw new InvalidOperationException($"Query '{queryName}' not found."); - } - - UnloadFromDestinations(ctx.Book, queryName); - - result.Success = true; - return result; - } - finally - { - ComUtilities.Release(ref query); - } - }, cancellationToken: default); - } - - /// <summary> - /// Removes all load destinations for a query (ListObjects and Data Model connections). - /// Shared logic used by both <see cref="Unload"/> and <see cref="LoadTo"/> ConnectionOnly mode. - /// The query definition itself is preserved. - /// </summary> - private static void UnloadFromDestinations(dynamic workbook, string queryName) - { - dynamic? worksheets = null; - - try - { - // STEP 1: Remove ListObjects (tables) that reference this query - worksheets = workbook.Worksheets; - int worksheetCount = worksheets.Count; - - for (int i = 1; i <= worksheetCount; i++) - { - dynamic? sheet = null; - dynamic? listObjects = null; - - try - { - sheet = worksheets.Item(i); - listObjects = sheet.ListObjects; - int tableCount = listObjects.Count; - - // Iterate backwards to safely delete while iterating - for (int j = tableCount; j >= 1; j--) - { - dynamic? table = null; - dynamic? queryTable = null; - dynamic? oleDbConnection = null; - - try - { - table = listObjects.Item(j); - - // Check if this table has a QueryTable with our query - try - { - queryTable = table.QueryTable; - if (queryTable != null) - { - oleDbConnection = queryTable.WorkbookConnection?.OLEDBConnection; - if (oleDbConnection != null) - { - string? connString = oleDbConnection.Connection?.ToString() ?? ""; - // Check if connection string references our query - if (connString.Contains("Microsoft.Mashup.OleDb") && - connString.Contains($"Location={queryName}")) - { - // This table is associated with our query - delete it - table.Delete(); - } - } - } - } - catch (Exception ex) when (ex is COMException or System.Reflection.TargetInvocationException) - { - // Table doesn't have QueryTable property - skip - } - } - finally - { - ComUtilities.Release(ref oleDbConnection); - ComUtilities.Release(ref queryTable); - ComUtilities.Release(ref table); - } - } - } - finally - { - ComUtilities.Release(ref listObjects); - ComUtilities.Release(ref sheet); - } - } - - // STEP 2: Remove Data Model connections - // Data Model connections follow pattern: "Query - {queryName}" or "Query - {queryName} - suffix" - dynamic? connections = null; - try - { - connections = workbook.Connections; - var connectionsToDelete = new List<string>(); - - for (int i = 1; i <= connections.Count; i++) - { - dynamic? conn = null; - try - { - conn = connections.Item(i); - string connName = conn.Name?.ToString() ?? ""; - - // Check if this is a connection for our query - // Patterns: - // - "Query - {queryName}" (worksheet connection) - // - "Query - {queryName} (Data Model)" (Data Model connection) - // - "Query - {queryName} - suffix" (legacy pattern) - if (connName.Equals($"Query - {queryName}", StringComparison.OrdinalIgnoreCase) || - connName.StartsWith($"Query - {queryName} -", StringComparison.OrdinalIgnoreCase) || - connName.StartsWith($"Query - {queryName} (", StringComparison.OrdinalIgnoreCase)) - { - connectionsToDelete.Add(connName); - } - } - finally - { - ComUtilities.Release(ref conn); - } - } - - // Delete connections (must iterate separately to avoid modifying collection while enumerating) - foreach (var connName in connectionsToDelete) - { - dynamic? connToDelete = null; - try - { - connToDelete = connections.Item(connName); - connToDelete.Delete(); - } - catch (COMException) - { - // Connection may have already been deleted or is in use - safe to ignore - } - finally - { - ComUtilities.Release(ref connToDelete); - } - } - } - finally - { - ComUtilities.Release(ref connections); - } - } - finally - { - ComUtilities.Release(ref worksheets); - } - } -} - - - diff --git a/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryCommands.LoadTo.cs b/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryCommands.LoadTo.cs deleted file mode 100644 index ba51d486..00000000 --- a/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryCommands.LoadTo.cs +++ /dev/null @@ -1,462 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; -using Excel = Microsoft.Office.Interop.Excel; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Power Query LoadTo operations - STANDALONE implementation. -/// Uses ListObjects.Add() pattern (same as Create) for consistency. -/// Based on Microsoft WorkbookQuery and ListObject API. -/// </summary> -public partial class PowerQueryCommands -{ - /// <summary> - /// Applies load destination to an existing Power Query. - /// Uses ListObjects.Add() for worksheet loading (consistent with Create). - /// </summary> - /// <remarks> - /// Microsoft Docs Reference: - /// - ListObjects.Add method - Creates Excel Table with external data source - /// - QueryTable properties - Configure refresh behavior and formatting - /// - Connections.Add2 method - Load to Data Model with CreateModelConnection=true - /// - /// IMPORTANT: Uses ListObjects.Add() (not QueryTables.Add()) for worksheet loading. - /// This is the CORRECT approach per Microsoft docs and matches Create() behavior. - /// </remarks> - public OperationResult LoadTo( - IExcelBatch batch, - string queryName, - PowerQueryLoadMode loadMode, - string? targetSheet = null, - string? targetCellAddress = null) - { - // Validate inputs - bool requiresWorksheet = loadMode == PowerQueryLoadMode.LoadToTable || loadMode == PowerQueryLoadMode.LoadToBoth; - - if (requiresWorksheet && string.IsNullOrWhiteSpace(targetSheet)) - { - targetSheet = queryName; // Default to query name - } - - if (!string.IsNullOrWhiteSpace(targetCellAddress) && !requiresWorksheet) - { - throw new ArgumentException("targetCellAddress is only supported when loadMode is 'LoadToTable' or 'LoadToBoth'.", nameof(targetCellAddress)); - } - - targetCellAddress ??= "A1"; // Default cell address - - using var timeoutCts = new CancellationTokenSource(ComInteropConstants.DataOperationTimeout); - - return batch.Execute((ctx, ct) => - { - Excel.Queries? queries = null; - Excel.WorkbookQuery? query = null; - var result = new PowerQueryLoadResult - { - FilePath = batch.WorkbookPath, - QueryName = queryName, - LoadDestination = loadMode, - WorksheetName = targetSheet, - TargetCellAddress = targetCellAddress - }; - - try - { - // STEP 1: Find the Power Query - queries = ctx.Book.Queries; - query = null; - for (int i = 1; i <= queries.Count; i++) - { - dynamic? q = null; - try - { - q = queries.Item(i); - string qName = q.Name?.ToString() ?? ""; - if (qName.Equals(queryName, StringComparison.OrdinalIgnoreCase)) - { - query = q; - q = null; // Don't release - keeping reference - break; - } - } - finally - { - ComUtilities.Release(ref q!); - } - } - - if (query == null) - { - throw new InvalidOperationException($"Query '{queryName}' not found."); - } - - // STEP 2: Always clean up current destinations before applying the new load mode. - // This ensures clean state transitions in all directions: - // LoadToTable → DataModel (removes old ListObject + worksheet connection) - // DataModel → LoadToTable (removes old Data Model connection) - // LoadToBoth → ConnectionOnly (removes both destinations) - // etc. - UnloadFromDestinations(ctx.Book, queryName); - - // STEP 3: Apply load destination based on mode - switch (loadMode) - { - case PowerQueryLoadMode.LoadToTable: - LoadQueryToWorksheet(ctx.Book, queryName, targetSheet!, targetCellAddress, result); - break; - - case PowerQueryLoadMode.LoadToDataModel: - LoadQueryToDataModel(ctx.Book, queryName, result); - break; - - case PowerQueryLoadMode.LoadToBoth: - // For LoadToBoth, create TWO separate properly-named connections: - // 1. Worksheet connection: "Query - {name}" (created by LoadQueryToWorksheet) - // 2. Data Model connection: "Query - {name} (Data Model)" (with suffix to avoid conflict) - LoadQueryToWorksheet(ctx.Book, queryName, targetSheet!, targetCellAddress, result); - LoadQueryToDataModel(ctx.Book, queryName, result, " (Data Model)"); - break; - - case PowerQueryLoadMode.ConnectionOnly: - // UnloadFromDestinations already called above; just set result properties. - result.DataRefreshed = false; - result.RowsLoaded = 0; - result.TargetCellAddress = null; - result.Success = true; - break; - } - - // Set additional result properties - if (result.Success) - { - result.ConfigurationApplied = true; - result.DataRefreshed = (loadMode != PowerQueryLoadMode.ConnectionOnly); - } - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref query!); - ComUtilities.Release(ref queries!); - } - }, timeoutCts.Token); - } - - /// <summary> - /// Loads query data to a worksheet using ListObjects.Add (correct approach for Power Query). - /// SHARED IMPLEMENTATION - Used by both Create and LoadTo. - /// </summary> - /// <remarks> - /// This is extracted from Create.cs for reuse. Both Create and LoadTo should use - /// the same ListObjects.Add() pattern for consistency. - /// Matches Excel UI behavior: Creates worksheet if it doesn't exist, or loads to existing worksheet. - /// </remarks> - private static bool LoadQueryToWorksheet( - dynamic workbook, - string queryName, - string sheetName, - string targetCellAddress, - dynamic result) - { - dynamic? worksheets = null; - dynamic? sheet = null; - dynamic? destination = null; - dynamic? connections = null; - dynamic? connection = null; - dynamic? listObjects = null; - dynamic? listObject = null; - dynamic? queryTable = null; - - try - { - worksheets = workbook.Worksheets; - - // Check if worksheet exists (Excel UI behavior: validate occupied cells on existing sheets) - bool worksheetExists = false; - for (int i = 1; i <= worksheets.Count; i++) - { - dynamic? ws = null; - try - { - ws = worksheets.Item(i); - string wsName = ws.Name?.ToString() ?? ""; - if (wsName.Equals(sheetName, StringComparison.OrdinalIgnoreCase)) - { - worksheetExists = true; - sheet = ws; - ws = null; // Keep reference, don't release - break; - } - } - finally - { - ComUtilities.Release(ref ws!); - } - } - - // Create new worksheet if doesn't exist - if (!worksheetExists) - { - sheet = worksheets.Add(); - sheet.Name = sheetName; - } - - if (sheet == null) - { - throw new InvalidOperationException($"Cannot access worksheet '{sheetName}'"); - } - - // Get destination range - destination = sheet.Range[targetCellAddress]; - - // For existing worksheets, check if target area would overlap with existing tables - // Excel allows loading over cell data, but NOT over existing tables/PivotTables - if (worksheetExists) - { - // Check if any ListObjects (tables) would overlap with this location - // Excel error: "A table cannot overlap a range that contains a pivot table report, query results, protected cells or another table." - dynamic? existingTables = null; - try - { - existingTables = sheet.ListObjects; - int tableCount = existingTables.Count; - - if (tableCount > 0) - { - // Get destination cell row/column for comparison - int destRow = Convert.ToInt32(destination.Row); - int destCol = Convert.ToInt32(destination.Column); - - for (int i = 1; i <= tableCount; i++) - { - dynamic? table = null; - dynamic? tableRange = null; - try - { - table = existingTables.Item(i); - tableRange = table.Range; - - int tableStartRow = Convert.ToInt32(tableRange.Row); - int tableStartCol = Convert.ToInt32(tableRange.Column); - int tableEndRow = tableStartRow + Convert.ToInt32(tableRange.Rows.Count) - 1; - int tableEndCol = tableStartCol + Convert.ToInt32(tableRange.Columns.Count) - 1; - - // Check if destination cell would overlap with existing table - if (destRow >= tableStartRow && destRow <= tableEndRow && - destCol >= tableStartCol && destCol <= tableEndCol) - { - throw new InvalidOperationException($"Cell {targetCellAddress} on sheet '{sheetName}' overlaps with existing table."); - } - } - finally - { - ComUtilities.Release(ref tableRange); - ComUtilities.Release(ref table); - } - } - } - } - finally - { - ComUtilities.Release(ref existingTables); - } - - // Also check if target cell contains data (Excel UI validation) - dynamic? cellValue = destination.Value2; - bool cellHasData = cellValue != null && !string.IsNullOrWhiteSpace(cellValue.ToString()); - ComUtilities.Release(ref cellValue!); - - if (cellHasData) - { - throw new InvalidOperationException($"Target cell '{targetCellAddress}' on worksheet '{sheetName}' already contains data. Choose a different targetCellAddress or clear the existing data first."); - } - } - - // Step 1: Create connection with Connections.Add2() using proper naming - // This ensures the connection is named "Query - {queryName}" instead of generic "Connection", "Connection1", etc. - connections = workbook.Connections; - string connectionName = $"Query - {queryName}"; - string connectionDescription = $"Connection to the '{queryName}' query in the workbook."; - string connectionString = $"OLEDB;Provider=Microsoft.Mashup.OleDb.1;Data Source=$Workbook$;Location={queryName}"; - string commandText = $"SELECT * FROM [{queryName}]"; - - connection = connections.Add2( - Name: connectionName, - Description: connectionDescription, - ConnectionString: connectionString, - CommandText: commandText, - lCmdtype: 2, // xlCmdSql - CreateModelConnection: false, // Worksheet loading, NOT Data Model - ImportRelationships: false - ); - - // Step 2: Add ListObject using the connection object (not connection string) - // This reuses the properly-named connection instead of creating a new generic one - listObjects = sheet.ListObjects; - listObject = listObjects.Add( - 0, // SourceType: 0 = xlSrcExternal - connection, // Source: connection object (reuses existing named connection) - Type.Missing, // LinkSource - 1, // XlListObjectHasHeaders: xlYes - destination // Destination: starting cell - ); - - // Configure the QueryTable behind the ListObject - queryTable = listObject.QueryTable; - queryTable.CommandType = 2; // xlCmdSql - queryTable.CommandText = $"SELECT * FROM [{queryName}]"; - queryTable.AdjustColumnWidth = true; - queryTable.PreserveFormatting = true; - queryTable.BackgroundQuery = false; // Synchronous - queryTable.RefreshStyle = 1; // xlInsertDeleteCells - queryTable.PreserveColumnInfo = false; // Allow schema changes on refresh - - // Refresh to materialize the table - OleMessageFilter.EnterLongOperation(); - try - { - queryTable.Refresh(false); // Synchronous refresh - } - finally - { - OleMessageFilter.ExitLongOperation(); - } - - // Name the table after the query for predictable M-code referencing. - // Without this, Excel auto-assigns a generic name ("Table1", "Table2", etc.) - // which makes Excel.CurrentWorkbook(){[Name=queryName]}[Content] lookups unreliable. - try { listObject.Name = queryName; } - catch { /* Non-critical — if rename fails (e.g. name conflict), load still succeeded. */ } - - // Capture results - use ListObject Range for total rows, subtract header - dynamic? listObjectRange = listObject.Range; - int totalRows = listObjectRange != null ? Convert.ToInt32(listObjectRange.Rows.Count) : 0; - result.TargetCellAddress = targetCellAddress; - result.RowsLoaded = totalRows > 0 ? totalRows - 1 : 0; // Subtract header row - result.Success = true; - - ComUtilities.Release(ref listObjectRange!); - return true; - } - finally - { - ComUtilities.Release(ref queryTable); - ComUtilities.Release(ref listObject); - ComUtilities.Release(ref listObjects); - ComUtilities.Release(ref connection); - ComUtilities.Release(ref connections); - ComUtilities.Release(ref destination); - ComUtilities.Release(ref sheet); - ComUtilities.Release(ref worksheets); - } - } - - /// <summary> - /// Loads query data to the Data Model using Connections.Add2. - /// SHARED IMPLEMENTATION - Used by both Create and LoadTo. - /// </summary> - /// <param name="workbook">The workbook to load into.</param> - /// <param name="queryName">The Power Query name.</param> - /// <param name="result">Result object to populate.</param> - /// <param name="connectionNameSuffix">Optional suffix for connection name to avoid conflicts (e.g., " (Data Model)").</param> - private static bool LoadQueryToDataModel( - dynamic workbook, - string queryName, - dynamic result, - string? connectionNameSuffix = null) - { - dynamic? connections = null; - dynamic? connection = null; - - try - { - connections = workbook.Connections; - - // Use suffix if provided (for LoadToBoth to avoid conflict with worksheet connection) - string connectionName = string.IsNullOrEmpty(connectionNameSuffix) - ? $"Query - {queryName}" - : $"Query - {queryName}{connectionNameSuffix}"; - string description = $"Connection to the '{queryName}' query in the workbook."; - string connectionString = $"OLEDB;Provider=Microsoft.Mashup.OleDb.1;Data Source=$Workbook$;Location={queryName}"; - string commandText = $"\"{queryName}\""; - - connection = connections.Add2( - Name: connectionName, - Description: description, - ConnectionString: connectionString, - CommandText: commandText, - lCmdtype: 6, // Data Model command type - CreateModelConnection: true, // CRITICAL: This loads to Data Model - ImportRelationships: false - ); - - // Refresh the connection to actually load data into the Data Model. - // Without this call, the connection is registered but no data is materialized — - // the table never appears in the Data Model even though success is returned. - OleMessageFilter.EnterLongOperation(); - try - { - connection.Refresh(); - } - finally - { - OleMessageFilter.ExitLongOperation(); - } - - result.RowsLoaded = -1; // Data Model doesn't expose row count - result.TargetCellAddress = null; - result.Success = true; - return true; - } - finally - { - ComUtilities.Release(ref connection); - ComUtilities.Release(ref connections); - } - } - - /// <summary> - /// Gets an existing worksheet or creates a new one. - /// SHARED HELPER - Used by Create and LoadTo. - /// </summary> - private static dynamic? GetOrCreateWorksheet(dynamic worksheets, string sheetName) - { - - // Try to find existing worksheet - int count = worksheets.Count; - dynamic? sheet; - for (int i = 1; i <= count; i++) - { - dynamic? candidate = null; - try - { - candidate = worksheets.Item(i); - string name = candidate.Name ?? ""; - - if (name.Equals(sheetName, StringComparison.OrdinalIgnoreCase)) - { - sheet = candidate; - candidate = null; // Prevent release in finally - return sheet; - } - } - finally - { - if (candidate != null) - { - ComUtilities.Release(ref candidate); - } - } - } - - // Sheet not found, create new one - sheet = worksheets.Add(); - sheet.Name = sheetName; - return sheet; - } -} - - diff --git a/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryCommands.Refresh.cs b/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryCommands.Refresh.cs deleted file mode 100644 index df02e369..00000000 --- a/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryCommands.Refresh.cs +++ /dev/null @@ -1,202 +0,0 @@ -using System.Runtime.InteropServices; -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; -using Excel = Microsoft.Office.Interop.Excel; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Power Query refresh operations -/// </summary> -public partial class PowerQueryCommands -{ - /// <inheritdoc /> - public PowerQueryRefreshResult Refresh(IExcelBatch batch, string queryName, TimeSpan timeout, IProgress<ProgressInfo>? progress = null) - { - var result = new PowerQueryRefreshResult - { - FilePath = batch.WorkbookPath, - QueryName = queryName, - RefreshTime = DateTime.Now - }; - - // Validate query name - if (!ValidateQueryName(queryName, out string? validationError)) - { - throw new ArgumentException(validationError, nameof(queryName)); - } - - if (timeout <= TimeSpan.Zero) - { - timeout = ComInteropConstants.DataOperationTimeout; - } - else if (timeout.TotalMilliseconds > uint.MaxValue - 1) - { - // TimeSpan.Parse("1800") = 1800 days — too large for CancellationTokenSource (~49.7 day max) - timeout = TimeSpan.FromMilliseconds(uint.MaxValue - 1); - } - - using var timeoutCts = new CancellationTokenSource(timeout); - - return batch.Execute((ctx, ct) => - { - Excel.WorkbookQuery? query = null; - try - { - query = ComUtilities.FindQuery(ctx.Book, queryName); - if (query == null) - { - throw new InvalidOperationException($"Query '{queryName}' not found."); - } - - // Refresh the query - exceptions propagate from both: - // - QueryTable.Refresh() for worksheet queries - // - Connection.Refresh() for Data Model queries - progress?.Report(new ProgressInfo { Current = 0, Total = 1, Message = $"Refreshing '{queryName}'" }); - bool refreshed = RefreshConnectionByQueryName(ctx.Book, queryName, timeoutCts.Token); - - if (!refreshed) - { - throw new InvalidOperationException($"Could not find connection or table for query '{queryName}'."); - } - - result.HasErrors = false; - result.Success = true; - result.LoadedToSheet = DetermineLoadedSheet(ctx.Book, queryName); - - bool isLoadedToDataModel = IsQueryLoadedToDataModel(ctx.Book, queryName); - result.IsConnectionOnly = string.IsNullOrEmpty(result.LoadedToSheet) && !isLoadedToDataModel; - - progress?.Report(new ProgressInfo { Current = 1, Total = 1, Message = $"Refreshed '{queryName}'" }); - return result; - } - finally - { - ComUtilities.Release(ref query); - } - }, timeoutCts.Token); - } - - /// <summary> - /// Refreshes all Power Query queries in the workbook - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="timeout">Maximum time to wait for all refreshes to complete</param> - /// <exception cref="InvalidOperationException">Thrown when refresh fails</exception> - public OperationResult RefreshAll(IExcelBatch batch, TimeSpan timeout = default, IProgress<ProgressInfo>? progress = null) - { - if (timeout <= TimeSpan.Zero) - { - timeout = ComInteropConstants.DataOperationTimeout; - } - else if (timeout.TotalMilliseconds > uint.MaxValue - 1) - { - // TimeSpan.Parse("1800") = 1800 days — too large for CancellationTokenSource (~49.7 day max) - timeout = TimeSpan.FromMilliseconds(uint.MaxValue - 1); - } - - using var timeoutCts = new CancellationTokenSource(timeout); - - return batch.Execute((ctx, ct) => - { - Excel.Queries? queries = null; - - try - { - queries = ctx.Book.Queries; - int totalQueries = queries.Count; - var errors = new List<string>(); - - for (int i = 1; i <= totalQueries; i++) - { - Excel.WorkbookQuery? query = null; - try - { - query = queries.Item(i); - string queryName = query.Name; - - progress?.Report(new ProgressInfo { Current = i - 1, Total = totalQueries, Message = $"Refreshing '{queryName}' ({i}/{totalQueries})" }); - - // Use the same robust strategy as single-query Refresh: - // 1) QueryTable.Refresh(false) for worksheet-loaded queries - // 2) Connection.Refresh() for Data Model queries - bool refreshed; - try - { - refreshed = RefreshConnectionByQueryName(ctx.Book, queryName, timeoutCts.Token); - } - catch (COMException ex) - { - errors.Add($"{queryName}: {ex.Message}"); - continue; - } - - if (!refreshed) - { - errors.Add($"{queryName}: Could not find connection or table for query."); - } - } - finally - { - ComUtilities.Release(ref query!); - } - } - - // Throw if any errors occurred - if (errors.Count > 0) - { - throw new InvalidOperationException($"Some queries failed to refresh: {string.Join(", ", errors)}"); - } - - progress?.Report(new ProgressInfo { Current = totalQueries, Total = totalQueries, Message = "All queries refreshed" }); - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref queries!); - } - }, timeoutCts.Token); - } - - /// <summary> - /// Helper method to find connection for a query - /// </summary> - private static dynamic? FindConnectionForQuery(dynamic workbook, string queryName) - { - dynamic? connections = null; - try - { - connections = workbook.Connections; - for (int i = 1; i <= connections.Count; i++) - { - dynamic? conn = null; - try - { - conn = connections.Item(i); - string connName = conn.Name; - if (connName.Contains(queryName)) - { - return conn; - } - } - finally - { - if (conn != null && conn != connections.Item(i)) - { - ComUtilities.Release(ref conn!); - } - } - } - } - finally - { - ComUtilities.Release(ref connections!); - } - - return null; - } -} - - - diff --git a/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryCommands.Rename.cs b/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryCommands.Rename.cs deleted file mode 100644 index e45df59c..00000000 --- a/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryCommands.Rename.cs +++ /dev/null @@ -1,94 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; -using Excel = Microsoft.Office.Interop.Excel; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -public partial class PowerQueryCommands -{ - /// <inheritdoc /> - public RenameResult Rename(IExcelBatch batch, string oldName, string newName) - { - return batch.Execute((ctx, _) => - { - var result = new RenameResult - { - ObjectType = "power-query", - OldName = oldName, - NewName = newName, - NormalizedOldName = RenameNameRules.Normalize(oldName), - NormalizedNewName = RenameNameRules.Normalize(newName) - }; - - // Validate new name is not empty - if (RenameNameRules.IsEmpty(result.NormalizedNewName)) - { - result.Success = false; - result.ErrorMessage = "New query name cannot be empty or whitespace."; - return result; - } - - // No-op when normalized names are exactly equal - if (RenameNameRules.IsNoOp(result.NormalizedOldName, result.NormalizedNewName)) - { - result.Success = true; - return result; - } - - Excel.Queries? queries = null; - Excel.WorkbookQuery? targetQuery = null; - try - { - queries = ctx.Book.Queries; - - // Find target query (case-sensitive exact match first) - targetQuery = ComUtilities.FindQuery(ctx.Book, result.NormalizedOldName); - if (targetQuery == null) - { - result.Success = false; - result.ErrorMessage = $"Power Query '{result.NormalizedOldName}' not found."; - return result; - } - - // Collect existing query names for conflict detection - var existingNames = new List<string>(); - int count = queries.Count; - for (int i = 1; i <= count; i++) - { - dynamic? q = null; - try - { - q = queries.Item(i); - existingNames.Add((string)q.Name); - } - finally - { - ComUtilities.Release(ref q!); - } - } - - // Check for conflicts (case-insensitive, excluding target) - if (RenameNameRules.HasConflict(existingNames, result.NormalizedNewName, result.NormalizedOldName)) - { - result.Success = false; - result.ErrorMessage = $"A query named '{result.NormalizedNewName}' already exists (case-insensitive match)."; - return result; - } - - // Attempt COM rename (includes case-only renames) - targetQuery.Name = result.NormalizedNewName; - - result.Success = true; - return result; - } - finally - { - ComUtilities.Release(ref targetQuery!); - ComUtilities.Release(ref queries!); - } - }); - } -} - - diff --git a/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryCommands.Update.cs b/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryCommands.Update.cs deleted file mode 100644 index 03705e03..00000000 --- a/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryCommands.Update.cs +++ /dev/null @@ -1,309 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Formatting; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; -using Excel = Microsoft.Office.Interop.Excel; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Power Query Update operations - STANDALONE implementation. -/// Does NOT use any existing helper methods. -/// Based on external reference code pattern. -/// </summary> -public partial class PowerQueryCommands -{ - /// <summary> - /// Update Power Query M code. Preserves load configuration (worksheet/data model). - /// M code is automatically formatted using the powerqueryformatter.com API before saving. - /// - Worksheet queries: Uses QueryTable.Refresh(false) for synchronous refresh with column propagation - /// - Data Model queries: Uses connection.Refresh() to update the Data Model - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="queryName">Name of query to update</param> - /// <param name="mCode">New M code</param> - /// <param name="refresh">Whether to refresh data after update (default: true)</param> - /// <exception cref="ArgumentException">Thrown when queryName or mCode is invalid</exception> - /// <exception cref="InvalidOperationException">Thrown when query not found or update fails</exception> - public OperationResult Update(IExcelBatch batch, string queryName, string mCode, bool refresh = true) - { - if (!ValidateQueryName(queryName, out string? validationError)) - { - throw new ArgumentException(validationError, nameof(queryName)); - } - - if (string.IsNullOrWhiteSpace(mCode)) - { - throw new ArgumentException("M code cannot be empty", nameof(mCode)); - } - - // Format M code before saving (outside batch.Execute for async operation) - // Formatting is done synchronously to maintain method signature compatibility - // Falls back to original if formatting fails - string formattedMCode = MCodeFormatter.FormatAsync(mCode).GetAwaiter().GetResult(); - - return batch.Execute((ctx, ct) => - { - Excel.Queries? queries = null; - Excel.WorkbookQuery? query = null; - dynamic? worksheets = null; - dynamic? targetWorksheet = null; - dynamic? existingQueryTable = null; - - try - { - // STEP 1: Find the Power Query - queries = ctx.Book.Queries; - query = null; - for (int i = 1; i <= queries.Count; i++) - { - dynamic? q = null; - try - { - q = queries.Item(i); - string qName = q.Name?.ToString() ?? ""; - if (qName.Equals(queryName, StringComparison.OrdinalIgnoreCase)) - { - query = q; - q = null; // Don't release - we're keeping the reference - break; - } - } - finally - { - ComUtilities.Release(ref q!); - } - } - - if (query == null) - { - throw new InvalidOperationException($"Query '{queryName}' not found."); - } - - // STEP 2: Find existing QueryTable (preferred) or ListObject bound to this query - // Pattern 1: QueryTable created by LoadTo/Create (uses QueryTables.Add) - // Pattern 2: ListObject created by previous Update (uses ListObjects.Add) - bool foundQueryTable = false; - - worksheets = ctx.Book.Worksheets; - for (int ws = 1; ws <= worksheets.Count; ws++) - { - dynamic? worksheet = null; - try - { - worksheet = worksheets.Item(ws); - - // FIRST: Check for QueryTable (Pattern 1 - from LoadTo/Create) - dynamic? queryTables = null; - try - { - queryTables = worksheet.QueryTables; - for (int qt = 1; qt <= queryTables.Count; qt++) - { - dynamic? qTable = null; - dynamic? wbConn = null; - dynamic? oledbConn = null; - try - { - qTable = queryTables.Item(qt); - wbConn = qTable.WorkbookConnection; - if (wbConn == null) continue; - - // NOTE: Accessing OLEDBConnection on non-OLEDB connection types - // (e.g., Type=7 ThisWorkbookDataModel, Type=8 workbook connections) - // throws COMException 0x800A03EC. We must catch and skip. - try - { - oledbConn = wbConn.OLEDBConnection; - } - catch (System.Runtime.InteropServices.COMException) - { - continue; - } - if (oledbConn == null) continue; - - string connString = oledbConn.Connection?.ToString() ?? ""; - bool isMashup = connString.Contains("Provider=Microsoft.Mashup.OleDb.1", StringComparison.OrdinalIgnoreCase); - bool locationMatches = connString.Contains($"Location={queryName}", StringComparison.OrdinalIgnoreCase); - - if (isMashup && locationMatches) - { - existingQueryTable = qTable; - qTable = null; // Don't release - keeping reference - targetWorksheet = worksheet; - worksheet = null; // Don't release - foundQueryTable = true; - break; - } - } - finally - { - ComUtilities.Release(ref oledbConn!); - ComUtilities.Release(ref wbConn!); - ComUtilities.Release(ref qTable!); - } - } - } - finally - { - ComUtilities.Release(ref queryTables!); - } - - if (foundQueryTable) break; - - // SECOND: Check for ListObject (Pattern 2 - from previous Update) - dynamic? listObjects = null; - try - { - listObjects = worksheet.ListObjects; - for (int lo = 1; lo <= listObjects.Count; lo++) - { - dynamic? listObj = null; - dynamic? queryTable = null; - dynamic? wbConn = null; - dynamic? oledbConn = null; - try - { - listObj = listObjects.Item(lo); - - // NOTE: Accessing QueryTable on a regular Excel table (not from external data) - // throws COMException 0x800A03EC. We must catch and skip such tables. - try - { - queryTable = listObj.QueryTable; - } - catch (System.Runtime.InteropServices.COMException) - { - // Regular table without QueryTable - skip it - continue; - } - - if (queryTable == null) continue; - - wbConn = queryTable.WorkbookConnection; - if (wbConn == null) continue; - - // Non-OLEDB connection types (Type=7, Type=8) throw COMException - try { oledbConn = wbConn.OLEDBConnection; } - catch (System.Runtime.InteropServices.COMException) { continue; } - if (oledbConn == null) continue; - - string connString = oledbConn.Connection?.ToString() ?? ""; - bool isMashup = connString.Contains("Provider=Microsoft.Mashup.OleDb.1", StringComparison.OrdinalIgnoreCase); - bool locationMatches = connString.Contains($"Location={queryName}", StringComparison.OrdinalIgnoreCase); - - if (isMashup && locationMatches) - { - existingQueryTable = queryTable; - queryTable = null; // Don't release - keeping reference - targetWorksheet = worksheet; - worksheet = null; // Don't release - break; - } - } - finally - { - ComUtilities.Release(ref oledbConn!); - ComUtilities.Release(ref wbConn!); - ComUtilities.Release(ref queryTable!); - ComUtilities.Release(ref listObj!); - } - } - } - finally - { - ComUtilities.Release(ref listObjects!); - } - } - finally - { - if (worksheet != null && targetWorksheet == null) ComUtilities.Release(ref worksheet!); - } - - if (existingQueryTable != null) break; - } - - // STEP 3: Update the M code with formatted version - // Note: 0x800A03EC error can occur in certain workbook states (see Issue #323) - // Retry doesn't help - it's a workbook state issue, not transient - query.Formula = formattedMCode; - - // STEP 4: Refresh if requested - if (refresh) - { - if (existingQueryTable != null) - { - // Worksheet query: Use QueryTable.Refresh(false) for synchronous refresh - // This properly propagates column structure changes - existingQueryTable.Refresh(false); - } - else - { - // Data Model-only query (no worksheet table): Use connection.Refresh() - // Find the Power Query connection and refresh it - dynamic? connections = null; - try - { - connections = ctx.Book.Connections; - for (int i = 1; i <= connections.Count; i++) - { - dynamic? conn = null; - dynamic? oledbConn = null; - try - { - conn = connections.Item(i); - - // NOTE: Accessing OLEDBConnection on non-OLEDB connection types - // (e.g., Type=7 ThisWorkbookDataModel, Type=8 workbook connections) - // throws COMException 0x800A03EC. We must catch and skip. - try - { - oledbConn = conn.OLEDBConnection; - } - catch (System.Runtime.InteropServices.COMException) - { - continue; - } - if (oledbConn == null) continue; - - string connString = oledbConn.Connection?.ToString() ?? ""; - bool isMashup = connString.Contains("Provider=Microsoft.Mashup.OleDb.1", StringComparison.OrdinalIgnoreCase); - bool locationMatches = connString.Contains($"Location={queryName}", StringComparison.OrdinalIgnoreCase); - - if (isMashup && locationMatches) - { - conn.Refresh(); - break; - } - } - finally - { - ComUtilities.Release(ref oledbConn!); - ComUtilities.Release(ref conn!); - } - } - } - finally - { - ComUtilities.Release(ref connections!); - } - } - } - // Connection-only queries (no QueryTable, no Data Model connection) don't need refresh - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref existingQueryTable!); - ComUtilities.Release(ref targetWorksheet!); - ComUtilities.Release(ref worksheets!); - ComUtilities.Release(ref query!); - ComUtilities.Release(ref queries!); - } - }); - } - -} - - diff --git a/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryCommands.View.cs b/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryCommands.View.cs deleted file mode 100644 index 25dfdfec..00000000 --- a/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryCommands.View.cs +++ /dev/null @@ -1,226 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; -using Excel = Microsoft.Office.Interop.Excel; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Power Query View operations - STANDALONE implementation. -/// Based on Microsoft WorkbookQuery object model documentation. -/// </summary> -public partial class PowerQueryCommands -{ - /// <summary> - /// View Power Query details: M code, description, and load configuration. - /// STANDALONE implementation following Microsoft WorkbookQuery API. - /// </summary> - /// <remarks> - /// Microsoft Docs Reference: - /// - WorkbookQuery.Name property (Read/Write String) - /// - WorkbookQuery.Description property (Read/Write String) - /// - WorkbookQuery.Formula property (Read/Write String) - The Power Query M code - /// - /// Load configuration detection follows the pattern established in Update: - /// - QueryTable (from LoadTo/Create) - created via sheet.QueryTables.Add() - /// - ListObject (from previous Update) - created via sheet.ListObjects.Add() - /// Both are checked to determine if query is connection-only or loaded to worksheet. - /// </remarks> - public PowerQueryViewResult View(IExcelBatch batch, string queryName) - { - var result = new PowerQueryViewResult - { - FilePath = batch.WorkbookPath, - QueryName = queryName - }; - - if (!ValidateQueryName(queryName, out string? validationError)) - { - throw new ArgumentException(validationError, nameof(queryName)); - } - - return batch.Execute((ctx, ct) => - { - Excel.Queries? queries = null; - Excel.WorkbookQuery? query = null; - dynamic? worksheets = null; - - try - { - // STEP 1: Find the Power Query - queries = ctx.Book.Queries; - query = null; - for (int i = 1; i <= queries.Count; i++) - { - dynamic? q = null; - try - { - q = queries.Item(i); - string qName = q.Name?.ToString() ?? ""; - if (qName.Equals(queryName, StringComparison.OrdinalIgnoreCase)) - { - query = q; - q = null; // Don't release - keeping reference - break; - } - } - finally - { - ComUtilities.Release(ref q!); - } - } - - if (query == null) - { - throw new InvalidOperationException($"Query '{queryName}' not found."); - } - - // STEP 2: Read WorkbookQuery properties (per Microsoft docs) - string mCode = query.Formula?.ToString() ?? ""; - result.MCode = mCode; - result.CharacterCount = mCode.Length; - - // STEP 3: Detect load configuration (QueryTable or ListObject pattern) - // Same detection logic as Update() - check BOTH patterns - bool isLoadedToWorksheet = false; - - worksheets = ctx.Book.Worksheets; - for (int ws = 1; ws <= worksheets.Count; ws++) - { - dynamic? worksheet = null; - try - { - worksheet = worksheets.Item(ws); - - // FIRST: Check for QueryTable (Pattern 1 - from LoadTo/Create) - dynamic? queryTables = null; - try - { - queryTables = worksheet.QueryTables; - for (int qt = 1; qt <= queryTables.Count; qt++) - { - dynamic? qTable = null; - dynamic? wbConn = null; - dynamic? oledbConn = null; - try - { - qTable = queryTables.Item(qt); - wbConn = qTable.WorkbookConnection; - if (wbConn == null) continue; - - // Non-OLEDB connection types (Type=7, Type=8) throw COMException - try { oledbConn = wbConn.OLEDBConnection; } - catch (System.Runtime.InteropServices.COMException) { continue; } - if (oledbConn == null) continue; - - string connString = oledbConn.Connection?.ToString() ?? ""; - bool isMashup = connString.Contains("Provider=Microsoft.Mashup.OleDb.1", StringComparison.OrdinalIgnoreCase); - bool locationMatches = connString.Contains($"Location={queryName}", StringComparison.OrdinalIgnoreCase); - - if (isMashup && locationMatches) - { - isLoadedToWorksheet = true; - break; - } - } - finally - { - ComUtilities.Release(ref oledbConn!); - ComUtilities.Release(ref wbConn!); - ComUtilities.Release(ref qTable!); - } - } - } - finally - { - ComUtilities.Release(ref queryTables!); - } - - if (isLoadedToWorksheet) break; - - // SECOND: Check for ListObject (Pattern 2 - from previous Update) - dynamic? listObjects = null; - try - { - listObjects = worksheet.ListObjects; - for (int lo = 1; lo <= listObjects.Count; lo++) - { - dynamic? listObj = null; - dynamic? queryTable = null; - dynamic? wbConn = null; - dynamic? oledbConn = null; - try - { - listObj = listObjects.Item(lo); - - // NOTE: Accessing QueryTable on a regular Excel table (not from external data) - // throws COMException 0x800A03EC. We must catch and skip such tables. - try - { - queryTable = listObj.QueryTable; - } - catch (System.Runtime.InteropServices.COMException) - { - // Regular table without QueryTable - skip it - continue; - } - - if (queryTable == null) continue; - - wbConn = queryTable.WorkbookConnection; - if (wbConn == null) continue; - - // Non-OLEDB connection types (Type=7, Type=8) throw COMException - try { oledbConn = wbConn.OLEDBConnection; } - catch (System.Runtime.InteropServices.COMException) { continue; } - if (oledbConn == null) continue; - - string connString = oledbConn.Connection?.ToString() ?? ""; - bool isMashup = connString.Contains("Provider=Microsoft.Mashup.OleDb.1", StringComparison.OrdinalIgnoreCase); - bool locationMatches = connString.Contains($"Location={queryName}", StringComparison.OrdinalIgnoreCase); - - if (isMashup && locationMatches) - { - isLoadedToWorksheet = true; - break; - } - } - finally - { - ComUtilities.Release(ref oledbConn!); - ComUtilities.Release(ref wbConn!); - ComUtilities.Release(ref queryTable!); - ComUtilities.Release(ref listObj!); - } - } - } - finally - { - ComUtilities.Release(ref listObjects!); - } - } - finally - { - ComUtilities.Release(ref worksheet!); - } - - if (isLoadedToWorksheet) break; - } - - // STEP 4: Set load mode result - result.IsConnectionOnly = !isLoadedToWorksheet; - - result.Success = true; - return result; - } - finally - { - ComUtilities.Release(ref worksheets!); - ComUtilities.Release(ref query!); - ComUtilities.Release(ref queries!); - } - }); - } -} - - diff --git a/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryCommands.cs b/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryCommands.cs deleted file mode 100644 index c2e496be..00000000 --- a/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryCommands.cs +++ /dev/null @@ -1,212 +0,0 @@ -using System.Runtime.InteropServices; -using Sbroenne.ExcelMcp.ComInterop; - - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Power Query management commands - Core data layer (no console output) -/// </summary> -public partial class PowerQueryCommands : IPowerQueryCommands -{ - private readonly IDataModelCommands _dataModelCommands; - - /// <summary> - /// Constructor with dependency injection for atomic Data Model operations - /// </summary> - /// <param name="dataModelCommands">Data Model commands for atomic refresh operations in SetLoadToDataModelAsync</param> - public PowerQueryCommands(IDataModelCommands dataModelCommands) - { - _dataModelCommands = dataModelCommands ?? throw new ArgumentNullException(nameof(dataModelCommands)); - } - - /// <summary> - /// Validates Power Query name length and content - /// Excel limit: 80 characters for Power Query names - /// </summary> - /// <param name="queryName">Query name to validate</param> - /// <param name="errorMessage">Error message if validation fails</param> - /// <returns>True if valid, false otherwise</returns> - private static bool ValidateQueryName(string queryName, out string? errorMessage) - { - if (string.IsNullOrWhiteSpace(queryName)) - { - errorMessage = "Query name cannot be empty or whitespace"; - return false; - } - - if (queryName.Length > 80) - { - errorMessage = $"Query name exceeds Excel's 80-character limit (current length: {queryName.Length})"; - return false; - } - - errorMessage = null; - return true; - } - - /// <summary> - /// Parse COM exception to extract user-friendly Power Query error message - /// </summary> - private static string ParsePowerQueryError(COMException comEx) - { - var message = comEx.Message; - - if (message.Contains("authentication", StringComparison.OrdinalIgnoreCase)) - return "Data source authentication failed. Check credentials and permissions."; - if (message.Contains("could not reach", StringComparison.OrdinalIgnoreCase) || - message.Contains("unable to connect", StringComparison.OrdinalIgnoreCase)) - return "Cannot connect to data source. Check network connectivity."; - if (message.Contains("privacy level", StringComparison.OrdinalIgnoreCase) || - message.Contains("combine data", StringComparison.OrdinalIgnoreCase)) - return "Formula.Firewall error - privacy levels must be configured in Excel UI (cannot be automated)"; - if (message.Contains("syntax", StringComparison.OrdinalIgnoreCase)) - return "M code syntax error. Review query formula."; - if (message.Contains("permission", StringComparison.OrdinalIgnoreCase) || - message.Contains("access denied", StringComparison.OrdinalIgnoreCase)) - return "Access denied. Check file or data source permissions."; - - return message; // Return original if no pattern matches - } - - /// <summary> - /// Extracts file path from File.Contents() in M code - /// </summary> - private static string? ExtractFileContentsPath(string mCode) - { - // Parse: File.Contents("D:\path\to\file.xlsx") - // Also handles: File.Contents( "path" ) with optional whitespace - var match = System.Text.RegularExpressions.Regex.Match( - mCode, - @"File\.Contents\s*\(\s*""([^""]+)""\s*\)", - System.Text.RegularExpressions.RegexOptions.IgnoreCase); - - return match.Success ? match.Groups[1].Value : null; - } - - /// <summary> - /// Determine which worksheet a query is loaded to (if any). - /// Uses the same ListObjects + connection string matching as RefreshQueryTableByName - /// for reliable detection of modern Excel table (ListObject) queries. - /// </summary> - private static string? DetermineLoadedSheet(dynamic workbook, string queryName) - { - dynamic? worksheets = null; - try - { - worksheets = workbook.Worksheets; - for (int ws = 1; ws <= worksheets.Count; ws++) - { - dynamic? worksheet = null; - dynamic? listObjects = null; - try - { - worksheet = worksheets.Item(ws); - listObjects = worksheet.ListObjects; - - for (int lo = 1; lo <= listObjects.Count; lo++) - { - dynamic? listObject = null; - dynamic? queryTable = null; - try - { - listObject = listObjects.Item(lo); - - try - { - queryTable = listObject.QueryTable; - } - catch (COMException) - { - // ListObject has no QueryTable — skip - continue; - } - - if (queryTable == null) - { - continue; - } - - // Match by connection string: "OLEDB;...;Location=QueryName;..." - // This is the same strategy as RefreshQueryTableByName and is - // reliable regardless of what Excel assigns as the QueryTable.Name. - string? connection = queryTable.Connection?.ToString(); - if (connection != null && - connection.Contains($"Location={queryName}", StringComparison.OrdinalIgnoreCase)) - { - return worksheet.Name?.ToString(); - } - } - catch (COMException) - { - continue; - } - finally - { - ComUtilities.Release(ref queryTable); - ComUtilities.Release(ref listObject); - } - } - } - finally - { - ComUtilities.Release(ref listObjects); - ComUtilities.Release(ref worksheet); - } - } - } - finally - { - ComUtilities.Release(ref worksheets); - } - - return null; - } - - /// <summary> - /// Determines if a query is loaded to the Data Model - /// </summary> - private static bool IsQueryLoadedToDataModel(dynamic workbook, string queryName) - { - dynamic? model = null; - dynamic? modelTables = null; - try - { - model = workbook.Model; - modelTables = model.ModelTables; - - for (int i = 1; i <= modelTables.Count; i++) - { - dynamic? table = null; - try - { - table = modelTables.Item(i); - string tableName = table.Name?.ToString() ?? ""; - - // Match by query name (Excel may add prefixes/suffixes) - if (tableName.Contains(queryName, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - finally - { - ComUtilities.Release(ref table); - } - } - } - catch (System.Runtime.InteropServices.COMException) - { - // Data Model might not be available or accessible - } - finally - { - ComUtilities.Release(ref modelTables); - ComUtilities.Release(ref model); - } - - return false; - } -} - - diff --git a/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryHelpers.cs b/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryHelpers.cs deleted file mode 100644 index ca7c6b9c..00000000 --- a/src/ExcelMcp.Core/Commands/PowerQuery/PowerQueryHelpers.cs +++ /dev/null @@ -1,216 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Excel = Microsoft.Office.Interop.Excel; - -namespace Sbroenne.ExcelMcp.Core.PowerQuery; - -/// <summary> -/// Helper methods for Power Query operations -/// </summary> -public static class PowerQueryHelpers -{ - /// <summary> - /// Determines if a Power Query connection is orphaned (no corresponding query exists). - /// An orphaned connection is one that appears to be a Power Query connection (based on - /// connection string or naming pattern) but has no matching entry in the Queries collection. - /// This commonly occurs after query deletions, renames, or copy/paste operations in Excel. - /// - /// A connection is considered orphaned if: - /// 1. It's a Power Query connection (uses Microsoft.Mashup provider) - /// 2. AND EITHER: - /// a. It doesn't follow the standard "Query - {queryName}" naming pattern (e.g., "Connection", "Connection1") - /// b. OR it follows the pattern but the corresponding query no longer exists in Workbook.Queries - /// </summary> - /// <param name="workbook">Excel workbook COM object</param> - /// <param name="connection">Connection COM object</param> - /// <returns>True if connection is a Power Query connection with no corresponding query</returns> - public static bool IsOrphanedPowerQueryConnection(dynamic workbook, dynamic connection) - { - // First check if this is even a Power Query connection - if (!IsPowerQueryConnection(connection)) - { - return false; - } - - string connectionName = connection.Name?.ToString() ?? ""; - - // Check if connection follows the standard "Query - {queryName}" naming pattern - // Only connections with this pattern are considered "proper" Power Query connections - if (!connectionName.StartsWith("Query - ", StringComparison.OrdinalIgnoreCase)) - { - // Generic names like "Connection", "Connection1", etc. are ALWAYS orphaned - // even if their Location= points to an existing query. - // The proper connection for a query is always named "Query - {queryName}". - return true; - } - - // Extract the query name from the "Query - {queryName}" pattern - string expectedQueryName = connectionName["Query - ".Length..]; - - // Handle potential suffixes like "Query - Name - Model" (though rare) - int dashIndex = expectedQueryName.IndexOf(" -", StringComparison.Ordinal); - if (dashIndex > 0) - { - expectedQueryName = expectedQueryName[..dashIndex]; - } - - // Check if a query with this name exists - Excel.WorkbookQuery? query = null; - try - { - query = ComUtilities.FindQuery(workbook, expectedQueryName); - // If query is null, the connection is orphaned - return query == null; - } - finally - { - ComUtilities.Release(ref query); - } - } - - /// <summary> - /// Determines if a connection is a Power Query connection - /// </summary> - /// <param name="connection">Connection COM object</param> - /// <returns>True if connection is a Power Query connection</returns> - public static bool IsPowerQueryConnection(dynamic connection) - { - try - { - // Power Query connections use Microsoft.Mashup provider - // Check OLEDBConnection for Mashup provider - if (connection.Type == 1) // xlConnectionTypeOLEDB - { - string connectionString = connection.OLEDBConnection?.Connection?.ToString() ?? ""; - if (connectionString.Contains("Microsoft.Mashup.OleDb", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - - // Also check connection name pattern (Power Query connections are named "Query - Name") - string name = connection.Name?.ToString() ?? ""; - if (name.StartsWith("Query - ", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - catch (System.Runtime.InteropServices.COMException) - { - // If any error occurs, assume not a Power Query connection - } - - return false; - } - - /// <summary> - /// Removes QueryTables associated with a query or connection name from all worksheets - /// </summary> - /// <param name="workbook">Excel workbook COM object</param> - /// <param name="name">Name of the query or connection (spaces will be replaced with underscores for QueryTable names)</param> - public static void RemoveQueryTables(dynamic workbook, string name) - { - dynamic? worksheets = null; - - try - { - worksheets = workbook.Worksheets; - string normalizedName = name.Replace(" ", "_"); - - for (int ws = 1; ws <= worksheets.Count; ws++) - { - dynamic? worksheet = null; - dynamic? queryTables = null; - - try - { - worksheet = worksheets.Item(ws); - queryTables = worksheet.QueryTables; - - // Iterate backwards to safely delete items - for (int qt = queryTables.Count; qt >= 1; qt--) - { - dynamic? queryTable = null; - try - { - queryTable = queryTables.Item(qt); - string queryTableName = queryTable.Name?.ToString() ?? ""; - - // Match QueryTable names that contain the normalized name - if (queryTableName.Contains(normalizedName, StringComparison.OrdinalIgnoreCase)) - { - queryTable.Delete(); - } - } - finally - { - ComUtilities.Release(ref queryTable); - } - } - } - finally - { - ComUtilities.Release(ref queryTables); - ComUtilities.Release(ref worksheet); - } - } - } - catch (System.Runtime.InteropServices.COMException) - { - // Ignore errors when removing QueryTables - they may not exist - } - finally - { - ComUtilities.Release(ref worksheets); - } - } - - /// <summary> - /// Options for creating QueryTable connections - /// </summary> - public class QueryTableOptions - { - /// <summary> - /// Name of the query or connection - /// </summary> - public required string Name { get; init; } - - /// <summary> - /// Whether to refresh data in background - /// </summary> - public bool BackgroundQuery { get; init; } - - /// <summary> - /// Whether to refresh data when file opens - /// </summary> - public bool RefreshOnFileOpen { get; init; } - - /// <summary> - /// Whether to save password in connection - /// </summary> - public bool SavePassword { get; init; } - - /// <summary> - /// Whether to preserve column information - /// IMPORTANT: Set to FALSE to allow column structure changes when query is updated - /// If TRUE, column structure is locked at QueryTable creation time - /// </summary> - public bool PreserveColumnInfo { get; init; } - - /// <summary> - /// Whether to preserve formatting - /// </summary> - public bool PreserveFormatting { get; init; } = true; - - /// <summary> - /// Whether to auto-adjust column width - /// </summary> - public bool AdjustColumnWidth { get; init; } = true; - - /// <summary> - /// Whether to refresh immediately after creation - /// </summary> - public bool RefreshImmediately { get; init; } - } -} - - diff --git a/src/ExcelMcp.Core/Commands/Range/IRangeCommands.cs b/src/ExcelMcp.Core/Commands/Range/IRangeCommands.cs deleted file mode 100644 index 40c51956..00000000 --- a/src/ExcelMcp.Core/Commands/Range/IRangeCommands.cs +++ /dev/null @@ -1,304 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Attributes; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.Range; - -/// <summary> -/// Core range operations: get/set values and formulas, copy ranges, clear content, and discover data regions. -/// Use rangeedit for insert/delete/find/sort. Use rangeformat for styling/validation. -/// Use rangelink for hyperlinks and cell protection. -/// Calculation mode and explicit recalculation are handled by calculationmode. -/// -/// BEST PRACTICE: Use 'get-values' to check existing data before overwriting. -/// Use 'clear-contents' (not 'clear-all') to preserve cell formatting when clearing data. -/// set-values preserves existing formatting; use set-number-format after if format change needed. -/// -/// DATA FORMAT: values and formulas are 2D JSON arrays representing rows and columns. -/// Example: [[row1col1, row1col2], [row2col1, row2col2]] -/// Single cell returns [[value]] (always 2D). -/// -/// REQUIRED PARAMETERS: -/// - sheetName + rangeAddress for cell operations (e.g., sheetName='Sheet1', rangeAddress='A1:D10') -/// - For named ranges, use sheetName='' (empty string) and rangeAddress='MyNamedRange' -/// -/// COPY OPERATIONS: Specify source and target sheet/range for copy operations. -/// -/// NUMBER FORMATS: Use US locale format codes (e.g., '#,##0.00', 'mm/dd/yyyy', '0.00%'). -/// </summary> -[ServiceCategory("range", "Range")] -[McpTool("range", Title = "Range Operations", Destructive = true, Category = "data", - Description = "Core range operations: get/set values and formulas, copy ranges, clear content, discover data regions. Use range_edit for insert/delete/find/sort. Use range_format for styling/validation. Use range_link for hyperlinks/protection. Use calculation_mode for recalculation. EXCEL TABLES: If user asks to 'format as table', 'create a table', 'put data in an Excel Table' — do NOT try to use range for this. Use table(action:'create') on the data range to create a proper Excel Table with filter arrows, banded rows, and automatic expansion. DATA FORMAT: 2D JSON arrays [[row1col1,row1col2],[row2col1,row2col2]]. Single cell returns [[value]]. FILE INPUT: For set-values/set-formulas, provide EITHER inline values/formulas OR a valuesFile/formulasFile path to a .json or .csv file. Prefer file input for large datasets. BEST PRACTICE: get-values before overwriting, clear-contents (not clear-all) to preserve formatting. NAMED RANGES: Use sheetName='' and rangeAddress=namedRangeName.")] -public interface IRangeCommands -{ - // === VALUE OPERATIONS === - - /// <summary> - /// Gets values from a range as 2D array. - /// Single cell "A1" returns [[value]], range "A1:B2" returns [[v1,v2],[v3,v4]]. - /// Named ranges: Use empty sheetName and rangeAddress="NamedRange". - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet containing the range - REQUIRED for cell addresses, use empty string for named ranges only</param> - /// <param name="rangeAddress">Cell range address (e.g., 'A1', 'A1:D10', 'B:D') or named range name (e.g., 'SalesData')</param> - [ServiceAction("get-values")] - RangeValueResult GetValues(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress); - - /// <summary> - /// Sets values in a range from 2D array or file. - /// Provide EITHER values (inline JSON 2D array) OR valuesFile (path to .json or .csv file), not both. - /// JSON file: must contain a 2D array like [[1,2],[3,4]]. - /// CSV file: rows become array rows, comma-separated values become columns. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet containing the range - REQUIRED for cell addresses, use empty string for named ranges only</param> - /// <param name="rangeAddress">Cell range address matching data dimensions (e.g., 'A1' for [[value]], 'A1:B2' for [[v1,v2],[v3,v4]])</param> - /// <param name="values">2D array of values to set - rows are outer array, columns are inner array (e.g., [[1,2,3],[4,5,6]] for 2 rows x 3 cols). Optional if valuesFile is provided.</param> - /// <param name="valuesFile">Path to a JSON or CSV file containing the values. JSON: 2D array. CSV: rows/columns. Alternative to inline values parameter.</param> - [ServiceAction("set-values")] - OperationResult SetValues(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress, List<List<object?>>? values = null, string? valuesFile = null); - - // === FORMULA OPERATIONS === - - /// <summary> - /// Gets formulas from a range as 2D array (empty string if no formula). - /// Single cell "A1" returns [["=SUM(B:B)"]], range "A1:B2" returns [[f1,f2],[f3,f4]]. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet containing the range</param> - /// <param name="rangeAddress">Cell range address (e.g., 'A1', 'A1:D10', 'B:D') or named range name</param> - [ServiceAction("get-formulas")] - RangeFormulaResult GetFormulas(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress); - - /// <summary> - /// Sets formulas in a range from 2D array or file. - /// Provide EITHER formulas (inline JSON 2D array) OR formulasFile (path to .json file), not both. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet containing the range</param> - /// <param name="rangeAddress">Cell range address matching formulas dimensions (e.g., 'A1:B2' for 2x2 formula array)</param> - /// <param name="formulas">2D array of formulas to set - include '=' prefix (e.g., [['=A1+B1', '=SUM(A:A)'], ['=C1*2', '=AVERAGE(B:B)']]). Optional if formulasFile is provided.</param> - /// <param name="formulasFile">Path to a JSON file containing the formulas as a 2D array. Alternative to inline formulas parameter.</param> - [ServiceAction("set-formulas")] - OperationResult SetFormulas(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress, List<List<string>>? formulas = null, string? formulasFile = null); - - /// <summary> - /// Validates formulas for syntax errors, undefined functions, and other issues without applying them. - /// Detects common problems like undefined functions (e.g., GETVM3 without XA2. namespace), - /// invalid references, syntax errors, and circular references. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet containing the range</param> - /// <param name="rangeAddress">Cell range address to validate</param> - /// <param name="formulas">2D array of formulas to validate - include '=' prefix</param> - /// <param name="formulasFile">Path to a JSON file containing the formulas to validate. Alternative to inline formulas parameter.</param> - [ServiceAction("validate-formulas")] - RangeFormulaValidationResult ValidateFormulas(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress, List<List<string>>? formulas = null, string? formulasFile = null); - - // === CLEAR OPERATIONS === - - /// <summary> - /// Clears all content (values, formulas, formats) from range. - /// Excel COM: Range.Clear() - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet containing the range</param> - /// <param name="rangeAddress">Cell range address to clear (e.g., 'A1:D10')</param> - [ServiceAction("clear-all")] - OperationResult ClearAll(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress); - - /// <summary> - /// Clears only values and formulas (preserves formatting). - /// Excel COM: Range.ClearContents() - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet containing the range</param> - /// <param name="rangeAddress">Cell range address to clear (e.g., 'A1:D10')</param> - [ServiceAction("clear-contents")] - OperationResult ClearContents(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress); - - /// <summary> - /// Clears only formatting (preserves values and formulas). - /// Excel COM: Range.ClearFormats() - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet containing the range</param> - /// <param name="rangeAddress">Cell range address to clear (e.g., 'A1:D10')</param> - [ServiceAction("clear-formats")] - OperationResult ClearFormats(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress); - - // === COPY OPERATIONS === - - /// <summary> - /// Copies range to another location (all content). - /// Excel COM: Range.Copy() - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sourceSheet">Source worksheet name for copy operations</param> - /// <param name="sourceRange">Source range address for copy operations (e.g., 'A1:D10')</param> - /// <param name="targetSheet">Target worksheet name for copy operations</param> - /// <param name="targetRange">Target range address - can be single cell for paste destination (e.g., 'A1')</param> - [ServiceAction("copy")] - OperationResult Copy(IExcelBatch batch, [RequiredParameter] string sourceSheet, [RequiredParameter] string sourceRange, [RequiredParameter] string targetSheet, [RequiredParameter] string targetRange); - - /// <summary> - /// Copies only values (no formulas or formatting). - /// Excel COM: Range.PasteSpecial(xlPasteValues) - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sourceSheet">Source worksheet name for copy operations</param> - /// <param name="sourceRange">Source range address for copy operations (e.g., 'A1:D10')</param> - /// <param name="targetSheet">Target worksheet name for copy operations</param> - /// <param name="targetRange">Target range address - can be single cell for paste destination (e.g., 'A1')</param> - [ServiceAction("copy-values")] - OperationResult CopyValues(IExcelBatch batch, [RequiredParameter] string sourceSheet, [RequiredParameter] string sourceRange, [RequiredParameter] string targetSheet, [RequiredParameter] string targetRange); - - /// <summary> - /// Copies only formulas (no values or formatting). - /// Excel COM: Range.PasteSpecial(xlPasteFormulas) - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sourceSheet">Source worksheet name for copy operations</param> - /// <param name="sourceRange">Source range address for copy operations (e.g., 'A1:D10')</param> - /// <param name="targetSheet">Target worksheet name for copy operations</param> - /// <param name="targetRange">Target range address - can be single cell for paste destination (e.g., 'A1')</param> - [ServiceAction("copy-formulas")] - OperationResult CopyFormulas(IExcelBatch batch, [RequiredParameter] string sourceSheet, [RequiredParameter] string sourceRange, [RequiredParameter] string targetSheet, [RequiredParameter] string targetRange); - - // === NUMBER FORMAT OPERATIONS === - - /// <summary> - /// Gets number format codes from range (2D array matching range dimensions). - /// Excel COM: Range.NumberFormat - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet containing the range</param> - /// <param name="rangeAddress">Cell range address (e.g., 'A1:D10')</param> - /// <returns>2D array of format codes (e.g., [["$#,##0.00", "0.00%"], ["m/d/yyyy", "General"]])</returns> - [ServiceAction("get-number-formats")] - RangeNumberFormatResult GetNumberFormats(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress); - - /// <summary> - /// Sets uniform number format for entire range. - /// Excel COM: Range.NumberFormat = formatCode - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet containing the range</param> - /// <param name="rangeAddress">Cell range address (e.g., 'A1:D10')</param> - /// <param name="formatCode">Number format code in US locale (e.g., '#,##0.00' for numbers, 'mm/dd/yyyy' for dates, '0.00%' for percentages, 'General' for default, '@' for text)</param> - [ServiceAction("set-number-format")] - OperationResult SetNumberFormat(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress, [RequiredParameter] string formatCode); - - /// <summary> - /// Sets number formats cell-by-cell from 2D array or file. - /// Provide EITHER formats (inline JSON 2D array) OR formatsFile (path to .json file), not both. - /// Excel COM: Range.NumberFormat (per cell) - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet containing the range</param> - /// <param name="rangeAddress">Cell range address matching formats dimensions</param> - /// <param name="formats">2D array of format codes - same dimensions as target range (e.g., [['#,##0.00', '0.00%'], ['mm/dd/yyyy', 'General']]). Optional if formatsFile is provided.</param> - /// <param name="formatsFile">Path to a JSON file containing 2D array of format codes. Alternative to inline formats parameter.</param> - [ServiceAction("set-number-formats")] - OperationResult SetNumberFormats(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress, List<List<string>>? formats = null, string? formatsFile = null); - - // === DISCOVERY OPERATIONS === - - /// <summary> - /// Gets the used range (all non-empty cells) from worksheet. - /// Excel COM: Worksheet.UsedRange - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet</param> - [ServiceAction("get-used-range")] - RangeValueResult GetUsedRange(IExcelBatch batch, string sheetName); - - /// <summary> - /// Gets the current region (contiguous data block) around a cell. - /// Excel COM: Range.CurrentRegion - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet</param> - /// <param name="cellAddress">Single cell address (e.g., 'B5') - expands to contiguous data region around this cell</param> - [ServiceAction("get-current-region")] - RangeValueResult GetCurrentRegion(IExcelBatch batch, string sheetName, [RequiredParameter] string cellAddress); - - /// <summary> - /// Gets range information (address, dimensions, number formats). - /// Excel COM: Range.Address, Range.Rows.Count, Range.Columns.Count, Range.NumberFormat - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet</param> - /// <param name="rangeAddress">Cell range address (e.g., 'A1:D10')</param> - [ServiceAction("get-info")] - RangeInfoResult GetInfo(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress); -} - -// === SUPPORTING TYPES (shared by all range interfaces) === - -/// <summary> -/// Direction to shift cells when inserting -/// </summary> -public enum InsertShiftDirection -{ - /// <summary>Shift existing cells down</summary> - Down, - /// <summary>Shift existing cells right</summary> - Right -} - -/// <summary> -/// Direction to shift cells when deleting -/// </summary> -public enum DeleteShiftDirection -{ - /// <summary>Shift remaining cells up</summary> - Up, - /// <summary>Shift remaining cells left</summary> - Left -} - -/// <summary> -/// Options for find operations -/// </summary> -public class FindOptions -{ - /// <summary>Whether to match case</summary> - public bool MatchCase { get; set; } - - /// <summary>Whether to match entire cell content</summary> - public bool MatchEntireCell { get; set; } - - /// <summary>Whether to search in formulas</summary> - public bool SearchFormulas { get; set; } = true; - - /// <summary>Whether to search in values</summary> - public bool SearchValues { get; set; } = true; - - /// <summary>Whether to search in comments</summary> - public bool SearchComments { get; set; } -} - -/// <summary> -/// Options for replace operations -/// </summary> -public class ReplaceOptions : FindOptions -{ - /// <summary>Whether to replace all occurrences (true) or just first (false)</summary> - public bool ReplaceAll { get; set; } = true; -} - -/// <summary> -/// Sort column definition -/// </summary> -public class SortColumn -{ - /// <summary>Column index within range (1-based)</summary> - public int ColumnIndex { get; set; } - - /// <summary>Sort direction (true = ascending, false = descending)</summary> - public bool Ascending { get; set; } = true; -} - - - diff --git a/src/ExcelMcp.Core/Commands/Range/IRangeEditCommands.cs b/src/ExcelMcp.Core/Commands/Range/IRangeEditCommands.cs deleted file mode 100644 index 22e3f529..00000000 --- a/src/ExcelMcp.Core/Commands/Range/IRangeEditCommands.cs +++ /dev/null @@ -1,142 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Attributes; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.Range; - -/// <summary> -/// Range editing operations: insert/delete cells, rows, and columns; find/replace text; sort data. -/// Use range for values/formulas/copy/clear operations. -/// -/// INSERT/DELETE CELLS: Specify shift direction to control how surrounding cells move. -/// - Insert: 'Down' or 'Right' -/// - Delete: 'Up' or 'Left' -/// -/// INSERT/DELETE ROWS: Use row range like '5:10' to insert/delete rows 5-10. -/// INSERT/DELETE COLUMNS: Use column range like 'B:D' to insert/delete columns B-D. -/// -/// FIND/REPLACE: Search within the specified range with optional case/cell matching. -/// - Find returns up to 10 matching cell addresses with total count. -/// - Replace modifies all matches by default. -/// -/// SORT: Specify sortColumns as array of {columnIndex: 1, ascending: true} objects. -/// Column indices are 1-based relative to the range. -/// </summary> -[ServiceCategory("rangeedit", "RangeEdit")] -[McpTool("range_edit", Title = "Range Edit Operations", Destructive = true, Category = "data", - Description = "Range editing: insert/delete cells, rows, columns; find/replace text; sort data. INSERT/DELETE CELLS: shiftDirection controls cell movement (Down/Right for insert, Up/Left for delete). INSERT/DELETE ROWS: Use row range like 5:10. COLUMNS: Use column range like B:D. FIND: Returns up to 10 matches with total count, optional case/cell matching. REPLACE: Modifies all matches by default (replaceAll=true). SORT: sortColumns array of {columnIndex, ascending}, 1-based indices relative to range.")] -public interface IRangeEditCommands -{ - // === INSERT/DELETE CELL OPERATIONS === - - /// <summary> - /// Inserts blank cells, shifting existing cells down or right. - /// Excel COM: Range.Insert(shift) - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet containing the range</param> - /// <param name="rangeAddress">Cell range address where cells will be inserted (e.g., 'A1:D10')</param> - /// <param name="insertShift">Direction to shift existing cells: 'Down' or 'Right'</param> - [ServiceAction("insert-cells")] - OperationResult InsertCells( - IExcelBatch batch, string sheetName, - [RequiredParameter] string rangeAddress, - [RequiredParameter] - [FromString] InsertShiftDirection insertShift); - - /// <summary> - /// Deletes cells, shifting remaining cells up or left. - /// Excel COM: Range.Delete(shift) - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet containing the range</param> - /// <param name="rangeAddress">Cell range address to delete (e.g., 'A1:D10')</param> - /// <param name="deleteShift">Direction to shift remaining cells: 'Up' or 'Left'</param> - [ServiceAction("delete-cells")] - OperationResult DeleteCells( - IExcelBatch batch, string sheetName, - [RequiredParameter] string rangeAddress, - [RequiredParameter] - [FromString] DeleteShiftDirection deleteShift); - - /// <summary> - /// Inserts entire rows above the range. - /// Excel COM: Range.EntireRow.Insert() - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet</param> - /// <param name="rangeAddress">Row range defining rows to insert above (e.g., '5:10' for rows 5-10)</param> - [ServiceAction("insert-rows")] - OperationResult InsertRows(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress); - - /// <summary> - /// Deletes entire rows in the range. - /// Excel COM: Range.EntireRow.Delete() - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet</param> - /// <param name="rangeAddress">Row range defining rows to delete (e.g., '5:10' for rows 5-10)</param> - [ServiceAction("delete-rows")] - OperationResult DeleteRows(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress); - - /// <summary> - /// Inserts entire columns to the left of the range. - /// Excel COM: Range.EntireColumn.Insert() - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet</param> - /// <param name="rangeAddress">Column range defining columns to insert left of (e.g., 'B:D' for columns B-D)</param> - [ServiceAction("insert-columns")] - OperationResult InsertColumns(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress); - - /// <summary> - /// Deletes entire columns in the range. - /// Excel COM: Range.EntireColumn.Delete() - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet</param> - /// <param name="rangeAddress">Column range defining columns to delete (e.g., 'B:D' for columns B-D)</param> - [ServiceAction("delete-columns")] - OperationResult DeleteColumns(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress); - - // === FIND/REPLACE OPERATIONS === - - /// <summary> - /// Finds all cells matching criteria in range. - /// Excel COM: Range.Find() - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet containing the range</param> - /// <param name="rangeAddress">Cell range address to search within (e.g., 'A1:D100')</param> - /// <param name="searchValue">Text or value to search for</param> - /// <param name="findOptions">Search options: matchCase (default: false), matchEntireCell (default: false), searchFormulas (default: true)</param> - [ServiceAction("find")] - RangeFindResult Find(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress, [RequiredParameter] string searchValue, FindOptions findOptions); - - /// <summary> - /// Replaces text/values in range. - /// Excel COM: Range.Replace() - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet containing the range</param> - /// <param name="rangeAddress">Cell range address to search within (e.g., 'A1:D100')</param> - /// <param name="findValue">Text or value to search for</param> - /// <param name="replaceValue">Text or value to replace matches with</param> - /// <param name="replaceOptions">Replace options: matchCase (default: false), matchEntireCell (default: false), replaceAll (default: true)</param> - [ServiceAction("replace")] - OperationResult Replace(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress, [RequiredParameter] string findValue, [RequiredParameter] string replaceValue, ReplaceOptions replaceOptions); - - // === SORT OPERATIONS === - - /// <summary> - /// Sorts range by one or more columns. - /// Excel COM: Range.Sort() - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet containing the range</param> - /// <param name="rangeAddress">Cell range address to sort (e.g., 'A1:D100')</param> - /// <param name="sortColumns">Array of sort specifications: [{columnIndex: 1, ascending: true}, ...] - columnIndex is 1-based relative to range</param> - /// <param name="hasHeaders">Whether the range has a header row to exclude from sorting (default: true)</param> - [ServiceAction("sort")] - OperationResult Sort(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress, [RequiredParameter] List<SortColumn> sortColumns, bool hasHeaders = true); -} diff --git a/src/ExcelMcp.Core/Commands/Range/IRangeFormatCommands.cs b/src/ExcelMcp.Core/Commands/Range/IRangeFormatCommands.cs deleted file mode 100644 index a0d9d287..00000000 --- a/src/ExcelMcp.Core/Commands/Range/IRangeFormatCommands.cs +++ /dev/null @@ -1,238 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Attributes; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.Range; - -/// <summary> -/// Range formatting operations: apply styles, set fonts/colors/borders, add data validation, merge cells, auto-fit dimensions. -/// Use range tool for values/formulas/copy/clear operations. -/// -/// set-style: Apply a named Excel style (Heading 1, Good, Bad, Neutral, Normal). -/// Best for semantic status labels (Good/Bad/Neutral have fill colours and are theme-aware) and document hierarchy (Heading 1/2/3). -/// NOTE: Heading styles do NOT apply a fill colour — use format-range when you need a coloured header row. -/// -/// format-range: Apply any combination of bold, fillColor, fontColor, alignment, borders. -/// Required whenever you need a fill colour or custom branding. -/// Pass ALL desired properties in a SINGLE call — do not call format-range multiple times for the same range. -/// -/// COLORS: Hex '#RRGGBB' (e.g., '#FF0000' for red, '#00FF00' for green) -/// FONT: size in points (e.g., 12, 14, 16), alignment: 'left', 'center', 'right' / 'top', 'middle', 'bottom' -/// -/// DATA VALIDATION: Restrict cell input with validation rules: -/// - Types: 'list', 'whole', 'decimal', 'date', 'time', 'textLength', 'custom' -/// - For list validation, formula1 is the list source (e.g., '=$A$1:$A$10' or '"Option1,Option2,Option3"') -/// - Operators: 'between', 'notBetween', 'equal', 'notEqual', 'greaterThan', 'lessThan', 'greaterThanOrEqual', 'lessThanOrEqual' -/// -/// MERGE: Combines cells into one. Only top-left cell value is preserved. -/// </summary> -[ServiceCategory("rangeformat", "RangeFormat")] -[McpTool("range_format", Title = "Range Format Operations", Destructive = true, Category = "data", - Description = "Range formatting: styles, custom visual formatting, data validation, merge, auto-fit. " + - "set-style: Named styles (Good/Bad/Neutral have fills and are theme-aware; Heading 1/2/3 for document hierarchy; Normal to reset). " + - "NOTE: Heading styles do NOT include a fill colour — use format-range for coloured header rows. " + - "format-range: Custom formatting (bold, fillColor, fontColor, alignment, borders) — pass ALL properties IN ONE CALL, do not call multiple times for same range. " + - "COLORS: Hex #RRGGBB. FONT: size in points, alignment left/center/right, top/middle/bottom. " + - "DATA VALIDATION: Types list/whole/decimal/date/time/textLength/custom. For list: formula1 is source (=$A$1:$A$10 or \"A,B,C\"). " + - "MERGE: Only top-left cell value preserved. " + - "TABLES: For Excel Table visual styling use table(action:'set-style') — do not apply range_format to table header or data rows, table style manages all table formatting. " + - "PIVOTTABLES: Do not apply range_format to PivotTable cells — formatting is overwritten on the next refresh.")] -public interface IRangeFormatCommands -{ - // === STYLE OPERATIONS === - - /// <summary> - /// Applies built-in Excel cell style to range (recommended for consistency). - /// Excel COM: Range.Style = styleName - /// </summary> - /// <param name="batch">Excel batch context</param> - /// <param name="sheetName">Name of the worksheet containing the range</param> - /// <param name="rangeAddress">Cell range address (e.g., 'A1:D10')</param> - /// <param name="styleName">Built-in or custom style name (e.g., 'Heading 1', 'Good', 'Bad', 'Currency', 'Percent'). Use 'Normal' to reset.</param> - [ServiceAction("set-style")] - OperationResult SetStyle(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress, [RequiredParameter] string styleName); - - /// <summary> - /// Gets the current built-in style name applied to a range. - /// Excel COM: Range.Style.Name property - /// </summary> - /// <param name="sheetName">Name of the worksheet containing the range</param> - /// <param name="rangeAddress">Cell range address (e.g., 'A1:D10')</param> - [ServiceAction("get-style")] - RangeStyleResult GetStyle(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress); - - /// <summary> - /// Applies custom visual formatting to a range (font, fill, border, alignment). - /// Use when built-in styles (set-style) don't meet your needs. - /// Excel COM: Range.Font, Range.Interior, Range.Borders, Range.HorizontalAlignment, etc. - /// Pass ALL desired properties in a SINGLE call — do not call format-range multiple times for the same range. - /// </summary> - /// <param name="sheetName">Name of the worksheet containing the range</param> - /// <param name="rangeAddress">Cell range address to format (e.g., 'A1:D10')</param> - /// <param name="fontName">Font family name (e.g., 'Arial', 'Calibri', 'Times New Roman')</param> - /// <param name="fontSize">Font size in points (e.g., 10, 11, 12, 14, 16)</param> - /// <param name="bold">Whether to apply bold formatting</param> - /// <param name="italic">Whether to apply italic formatting</param> - /// <param name="underline">Whether to apply underline formatting</param> - /// <param name="fontColor">Font (foreground) color as hex '#RRGGBB' (e.g., '#FF0000' for red)</param> - /// <param name="fillColor">Cell fill (background) color as hex '#RRGGBB' (e.g., '#FFFF00' for yellow)</param> - /// <param name="borderStyle">Border line style: 'continuous', 'dash', 'dot', 'dashdot', 'dashdotdot', 'double', 'slantdashdot', 'none'</param> - /// <param name="borderColor">Border color as hex '#RRGGBB'</param> - /// <param name="borderWeight">Border weight: 'hairline', 'thin', 'medium', 'thick'</param> - /// <param name="horizontalAlignment">Horizontal text alignment: 'left', 'center', 'right', 'justify', 'fill'</param> - /// <param name="verticalAlignment">Vertical text alignment: 'top', 'center' (or 'middle'), 'bottom', 'justify'</param> - /// <param name="wrapText">Whether to wrap text within cells</param> - /// <param name="orientation">Text rotation in degrees (-90 to 90, or 255 for vertical)</param> - /// <remarks> - /// For consistent, professional formatting, prefer SetStyle with built-in styles. - /// Use FormatRange only when built-in styles don't meet your needs. - /// </remarks> - [ServiceAction("format-range")] - OperationResult FormatRange( - IExcelBatch batch, - string sheetName, - [RequiredParameter] string rangeAddress, - string? fontName, - double? fontSize, - bool? bold, - bool? italic, - bool? underline, - string? fontColor, - string? fillColor, - string? borderStyle, - string? borderColor, - string? borderWeight, - string? horizontalAlignment, - string? verticalAlignment, - bool? wrapText, - int? orientation); - - // === VALIDATION OPERATIONS === - - /// <summary> - /// Adds data validation rules to range. - /// Excel COM: Range.Validation.Add() - /// </summary> - /// <param name="sheetName">Name of the worksheet containing the range</param> - /// <param name="rangeAddress">Cell range address to validate (e.g., 'A1:D10')</param> - /// <param name="validationType">Data validation type: 'list', 'whole', 'decimal', 'date', 'time', 'textLength', 'custom'</param> - /// <param name="validationOperator">Validation comparison operator: 'between', 'notBetween', 'equal', 'notEqual', 'greaterThan', 'lessThan', 'greaterThanOrEqual', 'lessThanOrEqual'</param> - /// <param name="formula1">First validation formula/value - for list validation use range '=$A$1:$A$10' or inline '"A,B,C"'</param> - /// <param name="formula2">Second validation formula/value - required only for 'between' and 'notBetween' operators</param> - /// <param name="showInputMessage">Whether to show input message when cell is selected (default: false)</param> - /// <param name="inputTitle">Title for the input message popup</param> - /// <param name="inputMessage">Text for the input message popup</param> - /// <param name="showErrorAlert">Whether to show error alert on invalid input (default: true)</param> - /// <param name="errorStyle">Error alert style: 'stop' (prevents entry), 'warning' (allows override), 'information' (allows entry)</param> - /// <param name="errorTitle">Title for the error alert popup</param> - /// <param name="errorMessage">Text for the error alert popup</param> - /// <param name="ignoreBlank">Whether to allow blank cells in validation (default: true)</param> - /// <param name="showDropdown">Whether to show dropdown arrow for list validation (default: true)</param> - [ServiceAction("validate-range")] - OperationResult ValidateRange( - IExcelBatch batch, - string sheetName, - [RequiredParameter] string rangeAddress, - [RequiredParameter] string validationType, - string? validationOperator, - string? formula1, - string? formula2, - bool? showInputMessage, - string? inputTitle, - string? inputMessage, - bool? showErrorAlert, - string? errorStyle, - string? errorTitle, - string? errorMessage, - bool? ignoreBlank, - bool? showDropdown); - - /// <summary> - /// Gets data validation settings from first cell in range. - /// Excel COM: Range.Validation - /// </summary> - /// <param name="sheetName">Name of the worksheet containing the range</param> - /// <param name="rangeAddress">Cell range address (e.g., 'A1:D10')</param> - [ServiceAction("get-validation")] - RangeValidationResult GetValidation(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress); - - /// <summary> - /// Removes data validation from range. - /// Excel COM: Range.Validation.Delete() - /// </summary> - /// <param name="sheetName">Name of the worksheet containing the range</param> - /// <param name="rangeAddress">Cell range address (e.g., 'A1:D10')</param> - [ServiceAction("remove-validation")] - OperationResult RemoveValidation(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress); - - // === AUTO-FIT OPERATIONS === - - /// <summary> - /// Auto-fits column widths to content. - /// Excel COM: Range.Columns.AutoFit() - /// </summary> - /// <param name="sheetName">Name of the worksheet</param> - /// <param name="rangeAddress">Column range to auto-fit (e.g., 'A:D' or 'A1:D100')</param> - [ServiceAction("auto-fit-columns")] - OperationResult AutoFitColumns(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress); - - /// <summary> - /// Auto-fits row heights to content. - /// Excel COM: Range.Rows.AutoFit() - /// </summary> - /// <param name="sheetName">Name of the worksheet</param> - /// <param name="rangeAddress">Row range to auto-fit (e.g., '1:10' or 'A1:D100')</param> - [ServiceAction("auto-fit-rows")] - OperationResult AutoFitRows(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress); - - // === MERGE OPERATIONS === - - /// <summary> - /// Merges cells in range into a single cell. - /// Excel COM: Range.Merge() - /// </summary> - /// <param name="sheetName">Name of the worksheet</param> - /// <param name="rangeAddress">Cell range to merge into a single cell (e.g., 'A1:D1')</param> - [ServiceAction("merge-cells")] - OperationResult MergeCells(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress); - - /// <summary> - /// Unmerges previously merged cells. - /// Excel COM: Range.UnMerge() - /// </summary> - /// <param name="sheetName">Name of the worksheet</param> - /// <param name="rangeAddress">Cell range to unmerge (e.g., 'A1:D1')</param> - [ServiceAction("unmerge-cells")] - OperationResult UnmergeCells(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress); - - /// <summary> - /// Checks if range contains merged cells. - /// Excel COM: Range.MergeCells - /// </summary> - /// <param name="sheetName">Name of the worksheet</param> - /// <param name="rangeAddress">Cell range to check for merged cells (e.g., 'A1:D10')</param> - [ServiceAction("get-merge-info")] - RangeMergeInfoResult GetMergeInfo(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress); - - // === SIZING OPERATIONS === - - /// <summary> - /// Sets the width of columns in a range. - /// Excel COM: Range.ColumnWidth property - /// </summary> - /// <param name="sheetName">Name of the worksheet</param> - /// <param name="rangeAddress">Column range to set width (e.g., 'A:A' or 'A1:D100')</param> - /// <param name="columnWidth">Width in points (1 point = 1/72 inch, approx 0.35mm). Standard width ~8.43 points. Range: 0.25-409 points.</param> - [ServiceAction("set-column-width")] - OperationResult SetColumnWidth(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress, [RequiredParameter] double columnWidth); - - /// <summary> - /// Sets the height of rows in a range. - /// Excel COM: Range.RowHeight property - /// </summary> - /// <param name="sheetName">Name of the worksheet</param> - /// <param name="rangeAddress">Row range to set height (e.g., '1:10' or 'A1:D100')</param> - /// <param name="rowHeight">Height in points (1 point = 1/72 inch, approx 0.35mm). Default row height ~15 points. Range: 0-409 points.</param> - [ServiceAction("set-row-height")] - OperationResult SetRowHeight(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress, [RequiredParameter] double rowHeight); -} diff --git a/src/ExcelMcp.Core/Commands/Range/IRangeLinkCommands.cs b/src/ExcelMcp.Core/Commands/Range/IRangeLinkCommands.cs deleted file mode 100644 index 702c19c7..00000000 --- a/src/ExcelMcp.Core/Commands/Range/IRangeLinkCommands.cs +++ /dev/null @@ -1,88 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Attributes; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.Range; - -/// <summary> -/// Hyperlink and cell protection operations for Excel ranges. -/// Use range for values/formulas, rangeformat for styling. -/// -/// HYPERLINKS: -/// - 'add-hyperlink': Add a clickable hyperlink to a cell (URL can be web, file, or mailto) -/// - 'remove-hyperlink': Remove hyperlink(s) from cells while keeping the cell content -/// - 'list-hyperlinks': Get all hyperlinks on a worksheet -/// - 'get-hyperlink': Get hyperlink details for a specific cell -/// -/// CELL PROTECTION: -/// - 'set-cell-lock': Lock or unlock cells (only effective when sheet protection is enabled) -/// - 'get-cell-lock': Check if cells are locked -/// -/// Note: Cell locking only takes effect when the worksheet is protected. -/// </summary> -[ServiceCategory("rangelink", "RangeLink")] -[McpTool("range_link", Title = "Range Link Operations", Destructive = true, Category = "data", - Description = "Hyperlink and cell protection operations. HYPERLINKS: add-hyperlink (URL: web, file, mailto), remove-hyperlink (keeps cell content), list-hyperlinks (all on worksheet), get-hyperlink (specific cell). CELL PROTECTION: set-cell-lock/get-cell-lock (only effective when sheet protection is enabled).")] -public interface IRangeLinkCommands -{ - // === HYPERLINK OPERATIONS === - - /// <summary> - /// Adds hyperlink to a single cell. - /// Excel COM: Worksheet.Hyperlinks.Add() - /// </summary> - /// <param name="sheetName">Name of the worksheet</param> - /// <param name="cellAddress">Single cell address (e.g., 'A1')</param> - /// <param name="url">Hyperlink URL (web: 'https://...', file: 'file:///...', email: 'mailto:...')</param> - /// <param name="displayText">Text to display in the cell (optional, defaults to URL)</param> - /// <param name="tooltip">Tooltip text shown on hover (optional)</param> - [ServiceAction("add-hyperlink")] - OperationResult AddHyperlink(IExcelBatch batch, string sheetName, [RequiredParameter] string cellAddress, [RequiredParameter] string url, string? displayText = null, string? tooltip = null); - - /// <summary> - /// Removes hyperlink from a single cell or all hyperlinks from a range. - /// Excel COM: Range.Hyperlinks.Delete() - /// </summary> - /// <param name="sheetName">Name of the worksheet</param> - /// <param name="rangeAddress">Cell range address to remove hyperlinks from (e.g., 'A1:D10')</param> - [ServiceAction("remove-hyperlink")] - OperationResult RemoveHyperlink(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress); - - /// <summary> - /// Lists all hyperlinks in a worksheet. - /// Excel COM: Worksheet.Hyperlinks collection - /// </summary> - /// <param name="sheetName">Name of the worksheet</param> - [ServiceAction("list-hyperlinks")] - RangeHyperlinkResult ListHyperlinks(IExcelBatch batch, string sheetName); - - /// <summary> - /// Gets hyperlink from a specific cell. - /// Excel COM: Range.Hyperlink - /// </summary> - /// <param name="sheetName">Name of the worksheet</param> - /// <param name="cellAddress">Single cell address (e.g., 'A1')</param> - [ServiceAction("get-hyperlink")] - RangeHyperlinkResult GetHyperlink(IExcelBatch batch, string sheetName, [RequiredParameter] string cellAddress); - - // === CELL PROTECTION OPERATIONS === - - /// <summary> - /// Locks or unlocks cells (requires worksheet protection to take effect). - /// Excel COM: Range.Locked - /// </summary> - /// <param name="sheetName">Name of the worksheet</param> - /// <param name="rangeAddress">Cell range address (e.g., 'A1:D10')</param> - /// <param name="locked">Lock status: true = locked (protected when sheet protection enabled), false = unlocked (editable)</param> - [ServiceAction("set-cell-lock")] - OperationResult SetCellLock(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress, [RequiredParameter] bool locked); - - /// <summary> - /// Gets lock status of first cell in range. - /// Excel COM: Range.Locked - /// </summary> - /// <param name="sheetName">Name of the worksheet</param> - /// <param name="rangeAddress">Cell range address (e.g., 'A1:D10')</param> - [ServiceAction("get-cell-lock")] - RangeLockInfoResult GetCellLock(IExcelBatch batch, string sheetName, [RequiredParameter] string rangeAddress); -} diff --git a/src/ExcelMcp.Core/Commands/Range/RangeCommands.Advanced.cs b/src/ExcelMcp.Core/Commands/Range/RangeCommands.Advanced.cs deleted file mode 100644 index 3040bca6..00000000 --- a/src/ExcelMcp.Core/Commands/Range/RangeCommands.Advanced.cs +++ /dev/null @@ -1,200 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.Range; - -/// <summary> -/// Merge, conditional formatting, and protection operations for Excel ranges (partial class) -/// </summary> -public partial class RangeCommands -{ - /// <inheritdoc /> - public OperationResult MergeCells( - IExcelBatch batch, - string sheetName, - string rangeAddress) - { - return batch.Execute((ctx, ct) => - { - dynamic? sheet = null; - dynamic? range = null; - - try - { - // Get sheet - sheet = string.IsNullOrEmpty(sheetName) - ? ctx.Book.ActiveSheet - : ctx.Book.Worksheets[sheetName]; - - // Get range - range = sheet.Range[rangeAddress]; - - // Merge cells - range.Merge(); - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref range!); - ComUtilities.Release(ref sheet!); - } - }); - } - - /// <inheritdoc /> - public OperationResult UnmergeCells( - IExcelBatch batch, - string sheetName, - string rangeAddress) - { - return batch.Execute((ctx, ct) => - { - dynamic? sheet = null; - dynamic? range = null; - - try - { - // Get sheet - sheet = string.IsNullOrEmpty(sheetName) - ? ctx.Book.ActiveSheet - : ctx.Book.Worksheets[sheetName]; - - // Get range - range = sheet.Range[rangeAddress]; - - // Unmerge cells - range.UnMerge(); - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref range!); - ComUtilities.Release(ref sheet!); - } - }); - } - - /// <inheritdoc /> - public RangeMergeInfoResult GetMergeInfo( - IExcelBatch batch, - string sheetName, - string rangeAddress) - { - return batch.Execute((ctx, ct) => - { - dynamic? sheet = null; - dynamic? range = null; - - try - { - // Get sheet - sheet = string.IsNullOrEmpty(sheetName) - ? ctx.Book.ActiveSheet - : ctx.Book.Worksheets[sheetName]; - - // Get range - range = sheet.Range[rangeAddress]; - - // Check if merged - var isMerged = range.MergeCells ?? false; - - return new RangeMergeInfoResult - { - Success = true, - FilePath = batch.WorkbookPath, - SheetName = sheetName, - RangeAddress = rangeAddress, - IsMerged = isMerged - }; - } - finally - { - ComUtilities.Release(ref range!); - ComUtilities.Release(ref sheet!); - } - }); - } - - /// <inheritdoc /> - public OperationResult SetCellLock( - IExcelBatch batch, - string sheetName, - string rangeAddress, - bool locked) - { - return batch.Execute((ctx, ct) => - { - dynamic? sheet = null; - dynamic? range = null; - - try - { - // Get sheet - sheet = string.IsNullOrEmpty(sheetName) - ? ctx.Book.ActiveSheet - : ctx.Book.Worksheets[sheetName]; - - // Get range - range = sheet.Range[rangeAddress]; - - // Set locked property - range.Locked = locked; - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref range!); - ComUtilities.Release(ref sheet!); - } - }); - } - - /// <inheritdoc /> - public RangeLockInfoResult GetCellLock( - IExcelBatch batch, - string sheetName, - string rangeAddress) - { - return batch.Execute((ctx, ct) => - { - dynamic? sheet = null; - dynamic? range = null; - - try - { - // Get sheet - sheet = string.IsNullOrEmpty(sheetName) - ? ctx.Book.ActiveSheet - : ctx.Book.Worksheets[sheetName]; - - // Get range - range = sheet.Range[rangeAddress]; - - // Get locked property - var isLocked = range.Locked ?? false; - - return new RangeLockInfoResult - { - Success = true, - FilePath = batch.WorkbookPath, - SheetName = sheetName, - RangeAddress = rangeAddress, - IsLocked = isLocked - }; - } - finally - { - ComUtilities.Release(ref range!); - ComUtilities.Release(ref sheet!); - } - }); - } - -} - - - diff --git a/src/ExcelMcp.Core/Commands/Range/RangeCommands.AutoFit.cs b/src/ExcelMcp.Core/Commands/Range/RangeCommands.AutoFit.cs deleted file mode 100644 index 0e08f5b8..00000000 --- a/src/ExcelMcp.Core/Commands/Range/RangeCommands.AutoFit.cs +++ /dev/null @@ -1,88 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.Range; - -/// <summary> -/// Auto-fit operations for Excel ranges (partial class) -/// </summary> -public partial class RangeCommands -{ - /// <inheritdoc /> - public OperationResult AutoFitColumns( - IExcelBatch batch, - string sheetName, - string rangeAddress) - { - return batch.Execute((ctx, ct) => - { - dynamic? sheet = null; - dynamic? range = null; - dynamic? columns = null; - - try - { - // Get sheet - sheet = string.IsNullOrEmpty(sheetName) - ? ctx.Book.ActiveSheet - : ctx.Book.Worksheets[sheetName]; - - // Get range - range = sheet.Range[rangeAddress]; - - // Get columns and auto-fit - columns = range.Columns; - columns.AutoFit(); - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref columns!); - ComUtilities.Release(ref range!); - ComUtilities.Release(ref sheet!); - } - }); - } - - /// <inheritdoc /> - public OperationResult AutoFitRows( - IExcelBatch batch, - string sheetName, - string rangeAddress) - { - return batch.Execute((ctx, ct) => - { - dynamic? sheet = null; - dynamic? range = null; - dynamic? rows = null; - - try - { - // Get sheet - sheet = string.IsNullOrEmpty(sheetName) - ? ctx.Book.ActiveSheet - : ctx.Book.Worksheets[sheetName]; - - // Get range - range = sheet.Range[rangeAddress]; - - // Get rows and auto-fit - rows = range.Rows; - rows.AutoFit(); - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref rows!); - ComUtilities.Release(ref range!); - ComUtilities.Release(ref sheet!); - } - }); - } -} - - - diff --git a/src/ExcelMcp.Core/Commands/Range/RangeCommands.Discovery.cs b/src/ExcelMcp.Core/Commands/Range/RangeCommands.Discovery.cs deleted file mode 100644 index 02958255..00000000 --- a/src/ExcelMcp.Core/Commands/Range/RangeCommands.Discovery.cs +++ /dev/null @@ -1,169 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; -using Excel = Microsoft.Office.Interop.Excel; - - -namespace Sbroenne.ExcelMcp.Core.Commands.Range; - -/// <summary> -/// Range discovery operations (UsedRange, CurrentRegion, RangeInfo) -/// </summary> -public partial class RangeCommands -{ - // === NATIVE EXCEL COM OPERATIONS (AI/LLM ESSENTIAL) === - - /// <summary> - /// Gets the used range (all non-empty cells) from worksheet - /// Excel COM: Worksheet.UsedRange - /// </summary> - public RangeValueResult GetUsedRange(IExcelBatch batch, string sheetName) - { - var result = new RangeValueResult - { - FilePath = batch.WorkbookPath, - SheetName = sheetName - }; - - return batch.Execute((ctx, ct) => - { - Excel.Worksheet? sheet = null; - dynamic? range = null; - try - { - sheet = ComUtilities.FindSheet(ctx.Book, sheetName); - if (sheet == null) - { - throw new InvalidOperationException($"Sheet '{sheetName}' not found."); - } - - range = sheet.UsedRange; - result.RangeAddress = range.Address; - - // Get values as 2D array - object[,]? values = range.Value2; - if (values != null) - { - result.RowCount = values.GetLength(0); - result.ColumnCount = values.GetLength(1); - - for (int r = 1; r <= result.RowCount; r++) - { - var row = new List<object?>(); - for (int c = 1; c <= result.ColumnCount; c++) - { - row.Add(values[r, c]); - } - result.Values.Add(row); - } - } - - result.Success = true; - return result; - } - finally - { - ComUtilities.Release(ref range); - ComUtilities.Release(ref sheet); - } - }); - } - - /// <inheritdoc /> - public RangeValueResult GetCurrentRegion(IExcelBatch batch, string sheetName, string cellAddress) - { - var result = new RangeValueResult - { - FilePath = batch.WorkbookPath, - SheetName = sheetName, - RangeAddress = cellAddress - }; - - return batch.Execute((ctx, ct) => - { - dynamic? cell = null; - dynamic? region = null; - try - { - cell = RangeHelpers.ResolveRange(ctx.Book, sheetName, cellAddress, out string? specificError); - if (cell == null) - { - throw new InvalidOperationException(specificError ?? RangeHelpers.GetResolveError(sheetName, cellAddress)); - } - - region = cell.CurrentRegion; - result.RangeAddress = region.Address; - - // Get values as 2D array - object[,]? values = region.Value2; - if (values != null) - { - result.RowCount = values.GetLength(0); - result.ColumnCount = values.GetLength(1); - - for (int r = 1; r <= result.RowCount; r++) - { - var row = new List<object?>(); - for (int c = 1; c <= result.ColumnCount; c++) - { - row.Add(values[r, c]); - } - result.Values.Add(row); - } - } - - result.Success = true; - return result; - } - finally - { - ComUtilities.Release(ref region); - ComUtilities.Release(ref cell); - } - }); - } - - /// <inheritdoc /> - public RangeInfoResult GetInfo(IExcelBatch batch, string sheetName, string rangeAddress) - { - var result = new RangeInfoResult - { - FilePath = batch.WorkbookPath, - SheetName = sheetName - }; - - return batch.Execute((ctx, ct) => - { - dynamic? range = null; - try - { - range = RangeHelpers.ResolveRange(ctx.Book, sheetName, rangeAddress, out string? specificError); - if (range == null) - { - throw new InvalidOperationException(specificError ?? RangeHelpers.GetResolveError(sheetName, rangeAddress)); - } - - result.Address = range.Address; - result.RowCount = range.Rows.Count; - result.ColumnCount = range.Columns.Count; - result.NumberFormat = range.NumberFormat?.ToString(); - - // Cell geometry properties (position and dimensions in points) - result.Left = Convert.ToDouble(range.Left); - result.Top = Convert.ToDouble(range.Top); - result.Width = Convert.ToDouble(range.Width); - result.Height = Convert.ToDouble(range.Height); - - result.Success = true; - return result; - } - finally - { - ComUtilities.Release(ref range); - } - }); - } -} - - - diff --git a/src/ExcelMcp.Core/Commands/Range/RangeCommands.Editing.cs b/src/ExcelMcp.Core/Commands/Range/RangeCommands.Editing.cs deleted file mode 100644 index fbfa86c8..00000000 --- a/src/ExcelMcp.Core/Commands/Range/RangeCommands.Editing.cs +++ /dev/null @@ -1,150 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; - - -namespace Sbroenne.ExcelMcp.Core.Commands.Range; - -/// <summary> -/// Range editing operations (clear, copy, insert/delete cells/rows/columns) -/// </summary> -public partial class RangeCommands -{ - // === CLEAR OPERATIONS === - - /// <summary> - /// Clears all content (values, formulas, formats) from range - /// Excel COM: Range.Clear() - /// </summary> - public OperationResult ClearAll(IExcelBatch batch, string sheetName, string rangeAddress) - { - return ClearRange(batch, sheetName, rangeAddress, "clear-all", r => r.Clear()); - } - - /// <inheritdoc /> - public OperationResult ClearContents(IExcelBatch batch, string sheetName, string rangeAddress) - { - return ClearRange(batch, sheetName, rangeAddress, "clear-contents", r => r.ClearContents()); - } - - /// <inheritdoc /> - public OperationResult ClearFormats(IExcelBatch batch, string sheetName, string rangeAddress) - { - return ClearRange(batch, sheetName, rangeAddress, "clear-formats", r => r.ClearFormats()); - } - - // === COPY OPERATIONS === - - /// <inheritdoc /> - public OperationResult Copy(IExcelBatch batch, string sourceSheet, string sourceRange, string targetSheet, string targetRange) - { - return CopyRange(batch, sourceSheet, sourceRange, targetSheet, targetRange, "copy", - (src, tgt) => src.Copy(tgt)); - } - - /// <inheritdoc /> - public OperationResult CopyValues(IExcelBatch batch, string sourceSheet, string sourceRange, string targetSheet, string targetRange) - { - return CopyRange(batch, sourceSheet, sourceRange, targetSheet, targetRange, "copy-values", - (src, tgt) => { src.Copy(); tgt.PasteSpecial(-4163); }); // xlPasteValues - } - - /// <inheritdoc /> - public OperationResult CopyFormulas(IExcelBatch batch, string sourceSheet, string sourceRange, string targetSheet, string targetRange) - { - return CopyRange(batch, sourceSheet, sourceRange, targetSheet, targetRange, "copy-formulas", - (src, tgt) => { src.Copy(); tgt.PasteSpecial(-4123); }); // xlPasteFormulas - } - - // === INSERT/DELETE OPERATIONS === - - /// <inheritdoc /> - public OperationResult InsertCells(IExcelBatch batch, string sheetName, string rangeAddress, InsertShiftDirection insertShift) - { - var result = new OperationResult { FilePath = batch.WorkbookPath, Action = "insert-cells" }; - - return batch.Execute((ctx, ct) => - { - dynamic? range = null; - try - { - range = RangeHelpers.ResolveRange(ctx.Book, sheetName, rangeAddress, out string? specificError); - if (range == null) - { - throw new InvalidOperationException(specificError ?? RangeHelpers.GetResolveError(sheetName, rangeAddress)); - } - - int shiftConst = insertShift == InsertShiftDirection.Down ? -4121 : -4161; // xlShiftDown : xlShiftToRight - range.Insert(shiftConst); - result.Success = true; - return result; - } - finally - { - ComUtilities.Release(ref range); - } - }); - } - - /// <inheritdoc /> - public OperationResult DeleteCells(IExcelBatch batch, string sheetName, string rangeAddress, DeleteShiftDirection deleteShift) - { - var result = new OperationResult { FilePath = batch.WorkbookPath, Action = "delete-cells" }; - - return batch.Execute((ctx, ct) => - { - dynamic? range = null; - try - { - range = RangeHelpers.ResolveRange(ctx.Book, sheetName, rangeAddress, out string? specificError); - if (range == null) - { - throw new InvalidOperationException(specificError ?? RangeHelpers.GetResolveError(sheetName, rangeAddress)); - } - - int shiftConst = deleteShift == DeleteShiftDirection.Up ? -4162 : -4159; // xlShiftUp : xlShiftToLeft - range.Delete(shiftConst); - result.Success = true; - return result; - } - finally - { - ComUtilities.Release(ref range); - } - }); - } - - /// <inheritdoc /> - public OperationResult InsertRows(IExcelBatch batch, string sheetName, string rangeAddress) - { - return ModifyRowsOrColumns(batch, sheetName, rangeAddress, "insert-rows", - r => r.EntireRow, rows => rows.Insert()); - } - - /// <inheritdoc /> - public OperationResult DeleteRows(IExcelBatch batch, string sheetName, string rangeAddress) - { - return ModifyRowsOrColumns(batch, sheetName, rangeAddress, "delete-rows", - r => r.EntireRow, rows => rows.Delete()); - } - - /// <inheritdoc /> - public OperationResult InsertColumns(IExcelBatch batch, string sheetName, string rangeAddress) - { - return ModifyRowsOrColumns(batch, sheetName, rangeAddress, "insert-columns", - r => r.EntireColumn, cols => cols.Insert()); - } - - /// <inheritdoc /> - public OperationResult DeleteColumns(IExcelBatch batch, string sheetName, string rangeAddress) - { - return ModifyRowsOrColumns(batch, sheetName, rangeAddress, "delete-columns", - r => r.EntireColumn, cols => cols.Delete()); - } - - // === FIND/REPLACE OPERATIONS === - -} - - - diff --git a/src/ExcelMcp.Core/Commands/Range/RangeCommands.Formatting.cs b/src/ExcelMcp.Core/Commands/Range/RangeCommands.Formatting.cs deleted file mode 100644 index 690a2993..00000000 --- a/src/ExcelMcp.Core/Commands/Range/RangeCommands.Formatting.cs +++ /dev/null @@ -1,289 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.Range; - -/// <summary> -/// Formatting operations for Excel ranges (partial class) -/// </summary> -public partial class RangeCommands -{ - private static readonly int[] BorderEdges = [7, 8, 9, 10]; - - /// <inheritdoc /> - public OperationResult SetStyle( - IExcelBatch batch, - string sheetName, - string rangeAddress, - string styleName) - { - return batch.Execute((ctx, ct) => - { - dynamic? sheet = null; - dynamic? range = null; - - try - { - // Get sheet - sheet = string.IsNullOrEmpty(sheetName) - ? ctx.Book.ActiveSheet - : ctx.Book.Worksheets[sheetName]; - - // Get range - range = sheet.Range[rangeAddress]; - - // Apply built-in style - range.Style = styleName; - - return new OperationResult - { - Success = true, - FilePath = batch.WorkbookPath, - Action = "set-style" - }; - } - finally - { - ComUtilities.Release(ref range!); - ComUtilities.Release(ref sheet!); - } - }); - } - - /// <inheritdoc /> - public RangeStyleResult GetStyle( - IExcelBatch batch, - string sheetName, - string rangeAddress) - { - return batch.Execute((ctx, ct) => - { - dynamic? sheet = null; - dynamic? range = null; - - try - { - // Get sheet - sheet = string.IsNullOrEmpty(sheetName) - ? ctx.Book.ActiveSheet - : ctx.Book.Worksheets[sheetName]; - - // Get range - range = sheet.Range[rangeAddress]; - - // Get style name from the first cell in the range - string styleName; - try - { - styleName = ComUtilities.SafeGetString(range.Style, "Name"); - if (string.IsNullOrEmpty(styleName)) - styleName = "Normal"; - } - catch (System.Runtime.InteropServices.COMException) - { - styleName = "Normal"; - } - - // Try to determine if it's a built-in style - bool isBuiltIn = false; - string? styleDescription = null; - - try - { - dynamic styles = ctx.Book.Styles; - dynamic style = styles.Item(styleName); - isBuiltIn = true; - - // Try to get additional information about the style - try - { - styleDescription = ComUtilities.SafeGetString(style, "NameLocal"); - if (string.IsNullOrEmpty(styleDescription)) - styleDescription = null; - } - catch (System.Runtime.InteropServices.COMException) - { - // Style description is optional - } - } - catch (System.Runtime.InteropServices.COMException) - { - // If we can't find it in the Styles collection, it might be a custom style - isBuiltIn = false; - } - - return new RangeStyleResult - { - Success = true, - FilePath = batch.WorkbookPath, - SheetName = sheetName, - RangeAddress = range.Address, - StyleName = styleName, - IsBuiltInStyle = isBuiltIn, - StyleDescription = styleDescription - }; - } - finally - { - ComUtilities.Release(ref range!); - ComUtilities.Release(ref sheet!); - } - }); - } - - /// <inheritdoc /> - public OperationResult FormatRange( - IExcelBatch batch, - string sheetName, - string rangeAddress, - string? fontName, - double? fontSize, - bool? bold, - bool? italic, - bool? underline, - string? fontColor, - string? fillColor, - string? borderStyle, - string? borderColor, - string? borderWeight, - string? horizontalAlignment, - string? verticalAlignment, - bool? wrapText, - int? orientation) - { - return batch.Execute((ctx, ct) => - { - dynamic? sheet = null; - dynamic? range = null; - dynamic? font = null; - dynamic? interior = null; - dynamic? borders = null; - - try - { - // Get sheet - sheet = string.IsNullOrEmpty(sheetName) - ? ctx.Book.ActiveSheet - : ctx.Book.Worksheets[sheetName]; - - // Get range - range = sheet.Range[rangeAddress]; - - // Apply font formatting - if (fontName != null || fontSize != null || bold != null || italic != null || underline != null || fontColor != null) - { - font = range.Font; - if (fontName != null) font.Name = fontName; - if (fontSize != null) font.Size = fontSize.Value; - if (bold != null) font.Bold = bold.Value; - if (italic != null) font.Italic = italic.Value; - if (underline != null) font.Underline = underline.Value ? 2 : -4142; // xlUnderlineStyleSingle : xlUnderlineStyleNone - if (fontColor != null) font.Color = FormattingHelpers.ParseColor(fontColor); - } - - // Apply fill color - if (fillColor != null) - { - interior = range.Interior; - interior.Color = FormattingHelpers.ParseColor(fillColor); - } - - // Apply borders - if (borderStyle != null || borderColor != null || borderWeight != null) - { - // Apply to all edges (7 = xlEdgeLeft, 8 = xlEdgeTop, 9 = xlEdgeBottom, 10 = xlEdgeRight) - foreach (var edge in BorderEdges) - { - dynamic? border = null; - try - { - border = range.Borders.Item(edge); - if (borderStyle != null) border.LineStyle = FormattingHelpers.ParseBorderStyle(borderStyle); - if (borderColor != null) border.Color = FormattingHelpers.ParseColor(borderColor); - if (borderWeight != null) border.Weight = ParseBorderWeight(borderWeight); - } - finally - { - ComUtilities.Release(ref border!); - } - } - } - - // Apply alignment - if (horizontalAlignment != null) - { - range.HorizontalAlignment = ParseHorizontalAlignment(horizontalAlignment); - } - if (verticalAlignment != null) - { - range.VerticalAlignment = ParseVerticalAlignment(verticalAlignment); - } - - // Apply text wrapping - if (wrapText != null) - { - range.WrapText = wrapText.Value; - } - - // Apply orientation - if (orientation != null) - { - range.Orientation = orientation.Value; - } - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref borders!); - ComUtilities.Release(ref interior!); - ComUtilities.Release(ref font!); - ComUtilities.Release(ref range!); - ComUtilities.Release(ref sheet!); - } - }); - } - - private static int ParseBorderWeight(string weight) - { - return weight.ToLowerInvariant() switch - { - "hairline" => 1, // xlHairline - "thin" => 2, // xlThin - "medium" => -4138, // xlMedium - "thick" => 4, // xlThick - _ => throw new ArgumentException($"Invalid border weight: {weight}") - }; - } - - private static int ParseHorizontalAlignment(string alignment) - { - return alignment.ToLowerInvariant() switch - { - "left" => -4131, // xlLeft - "center" => -4108, // xlCenter - "right" => -4152, // xlRight - "justify" => -4130, // xlJustify - "distributed" => -4117, // xlDistributed - _ => throw new ArgumentException($"Invalid horizontal alignment: {alignment}") - }; - } - - private static int ParseVerticalAlignment(string alignment) - { - return alignment.ToLowerInvariant() switch - { - "top" => -4160, // xlTop - "center" => -4108, // xlCenter - "middle" => -4108, // xlCenter (common alias) - "bottom" => -4107, // xlBottom - "justify" => -4130, // xlJustify - "distributed" => -4117, // xlDistributed - _ => throw new ArgumentException($"Invalid vertical alignment: {alignment}") - }; - } -} - - - diff --git a/src/ExcelMcp.Core/Commands/Range/RangeCommands.FormulaValidation.cs b/src/ExcelMcp.Core/Commands/Range/RangeCommands.FormulaValidation.cs deleted file mode 100644 index 504fede9..00000000 --- a/src/ExcelMcp.Core/Commands/Range/RangeCommands.FormulaValidation.cs +++ /dev/null @@ -1,224 +0,0 @@ -using System.Text.RegularExpressions; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; -using Sbroenne.ExcelMcp.Core.Utilities; - - -namespace Sbroenne.ExcelMcp.Core.Commands.Range; - -/// <summary> -/// Range formula validation operations -/// Improvement #1: Formula syntax validation with error detection and suggestions -/// </summary> -public partial class RangeCommands -{ - /// <inheritdoc /> - public RangeFormulaValidationResult ValidateFormulas(IExcelBatch batch, string sheetName, string rangeAddress, List<List<string>>? formulas = null, string? formulasFile = null) - { - // Resolve formulas from inline parameter or file - var resolvedFormulas = ParameterTransforms.ResolveFormulasOrFile(formulas, formulasFile); - - var result = new RangeFormulaValidationResult - { - FilePath = batch.WorkbookPath, - SheetName = sheetName, - RangeAddress = rangeAddress, - Formulas = resolvedFormulas, - FormulaCount = resolvedFormulas.Sum(row => row.Count), - IsValid = true - }; - - return batch.Execute((ctx, ct) => - { - var errors = new List<FormulaValidationError>(); - var warnings = new List<FormulaValidationWarning>(); - int validCount = 0; - - // Parse starting cell from rangeAddress (e.g., "B1" or "B1:B2") - var (startRow, startCol) = ParseCellAddress(rangeAddress.Split(':')[0]); - - int currentRow = startRow; - foreach (var formulaRow in resolvedFormulas) - { - int currentCol = startCol; - foreach (var formula in formulaRow) - { - // Generate cell address for error reporting - string cellAddress = GetCellAddress(currentRow, currentCol); - - // Skip empty formulas (cells without formulas) - if (string.IsNullOrWhiteSpace(formula)) - { - validCount++; - currentCol++; - continue; - } - - // Validate formula - var validationErrors = ValidateSingleFormula(formula, cellAddress, currentRow, currentCol); - if (validationErrors.Count == 0) - { - validCount++; - } - else - { - result.IsValid = false; - errors.AddRange(validationErrors); - } - - currentCol++; - } - currentRow++; - } - - result.ValidCount = validCount; - result.ErrorCount = errors.Count; - if (errors.Count > 0) - { - result.Errors = errors; - } - if (warnings.Count > 0) - { - result.Warnings = warnings; - } - - result.Success = true; - return result; - }); - } - - /// <summary> - /// Validates a single formula and returns list of validation errors - /// </summary> - private static List<FormulaValidationError> ValidateSingleFormula(string formula, string cellAddress, int row, int col) - { - var errors = new List<FormulaValidationError>(); - - if (!formula.StartsWith('=')) - { - errors.Add(new FormulaValidationError - { - CellAddress = cellAddress, - Row = row, - Column = col, - Formula = formula, - Message = "Formula must start with '=' character", - Category = "syntax-error" - }); - return errors; - } - - string formulaContent = formula[1..]; // Remove leading = - - // Check for unclosed parentheses - int openParen = 0; - foreach (char c in formulaContent) - { - if (c == '(') openParen++; - else if (c == ')') openParen--; - } - if (openParen != 0) - { - errors.Add(new FormulaValidationError - { - CellAddress = cellAddress, - Row = row, - Column = col, - Formula = formula, - Message = openParen > 0 - ? $"Missing {openParen} closing parenthesis(es)" - : $"Extra {-openParen} closing parenthesis(es)", - Category = "syntax-error" - }); - } - - // Check for common undefined functions without namespace - var excelAddInFunctions = new[] { "GETVM3", "GETAKS", "GETDISK", "GETESAN", "GETANF", "GETMANAGEDDISK4" }; - foreach (var func in excelAddInFunctions) - { - // Match function call without XA2. namespace (case-insensitive) - // Use simpler pattern: just look for function name followed by ( - if (formulaContent.Contains(func + "(", StringComparison.OrdinalIgnoreCase) || - formulaContent.Contains(func + " (", StringComparison.OrdinalIgnoreCase)) - { - // Make sure it's not already prefixed with XA2. - if (!formulaContent.Contains("XA2." + func, StringComparison.OrdinalIgnoreCase)) - { - errors.Add(new FormulaValidationError - { - CellAddress = cellAddress, - Row = row, - Column = col, - Formula = formula, - Message = $"Function '{func}' requires XA2 add-in namespace", - Suggestion = $"=XA2.{func}(...)", - Category = "undefined-function" - }); - } - } - } - - // Check for references to non-existent sheets (basic check for common patterns) - if (Regex.IsMatch(formulaContent, @"![A-Z]")) - { - // Basic detection of sheet references - report as potential issue - // Full validation would require access to actual sheet names in the workbook - errors.Add(new FormulaValidationError - { - CellAddress = cellAddress, - Row = row, - Column = col, - Formula = formula, - Message = "Formula contains sheet reference which requires verification", - Category = "invalid-reference" - }); - } - - return errors; - } - - /// <summary> - /// Converts row,col (1-based) to Excel cell address (e.g., "A1", "B5") - /// </summary> - private static string GetCellAddress(int row, int col) - { - // Convert column number to letter(s) - string colLetter = ""; - int temp = col; - while (temp > 0) - { - temp--; - colLetter = (char)('A' + (temp % 26)) + colLetter; - temp /= 26; - } - return colLetter + row; - } - - /// <summary> - /// Parses Excel cell address (e.g., "B1", "AA5") to (row, col) tuple - /// </summary> - private static (int row, int col) ParseCellAddress(string cellAddress) - { - // Extract column letters and row number - string colPart = ""; - string rowPart = ""; - - foreach (char c in cellAddress) - { - if (char.IsLetter(c)) - colPart += c; - else - rowPart += c; - } - - // Convert column letters to number (A=1, B=2, ..., Z=26, AA=27) - int col = 0; - foreach (char c in colPart) - { - col = col * 26 + (c - 'A' + 1); - } - - int row = string.IsNullOrEmpty(rowPart) ? 1 : int.Parse(rowPart, System.Globalization.CultureInfo.InvariantCulture); - return (row, col); - } -} diff --git a/src/ExcelMcp.Core/Commands/Range/RangeCommands.Formulas.cs b/src/ExcelMcp.Core/Commands/Range/RangeCommands.Formulas.cs deleted file mode 100644 index e2122ecf..00000000 --- a/src/ExcelMcp.Core/Commands/Range/RangeCommands.Formulas.cs +++ /dev/null @@ -1,239 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; -using Sbroenne.ExcelMcp.Core.Utilities; -using Excel = Microsoft.Office.Interop.Excel; - - -namespace Sbroenne.ExcelMcp.Core.Commands.Range; - -/// <summary> -/// Range formula operations (get/set formulas as 2D arrays) -/// </summary> -public partial class RangeCommands -{ - /// <inheritdoc /> - public RangeFormulaResult GetFormulas(IExcelBatch batch, string sheetName, string rangeAddress) - { - var result = new RangeFormulaResult - { - FilePath = batch.WorkbookPath, - SheetName = sheetName, - RangeAddress = rangeAddress, - CellErrors = [] - }; - - return batch.Execute((ctx, ct) => - { - dynamic? range = null; - try - { - range = RangeHelpers.ResolveRange(ctx.Book, sheetName, rangeAddress, out string? specificError); - if (range == null) - { - throw new InvalidOperationException(specificError ?? RangeHelpers.GetResolveError(sheetName, rangeAddress)); - } - - // Get actual address - result.RangeAddress = range.Address; - - // Get formulas and values - handle single cell case - // Use Formula2 (modern) instead of Formula (legacy) to avoid implicit intersection (@) - // operator being injected in Excel Table cells. Formula2 respects dynamic array semantics. - object formulaOrArray = range.Formula2; - object valueOrArray = range.Value2; - - if (formulaOrArray is object[,] formulas && valueOrArray is object[,] values) - { - // Multi-cell range - result.RowCount = formulas.GetLength(0); - result.ColumnCount = formulas.GetLength(1); - - for (int r = 1; r <= result.RowCount; r++) - { - var formulaRow = new List<string>(); - var valueRow = new List<object?>(); - - for (int c = 1; c <= result.ColumnCount; c++) - { - string formula = formulas[r, c]?.ToString() ?? string.Empty; - object? cellValue = values[r, c]; - - // Only return actual formulas (starting with =), not values - formulaRow.Add(formula.StartsWith('=') ? formula : string.Empty); - valueRow.Add(cellValue); - - // ERROR CODE DETECTION: Map Excel error codes to human-readable messages - if (cellValue is int errorCode && errorCode < 0) - { - var cellAddr = $"{GetColumnLetter(c)}{r}"; - result.CellErrors.Add(new RangeCellError - { - CellAddress = cellAddr, - ErrorCode = errorCode, - ErrorMessage = MapErrorCodeToMessage(errorCode) - }); - } - } - - result.Formulas.Add(formulaRow); - result.Values.Add(valueRow); - } - } - else - { - // Single cell - result.RowCount = 1; - result.ColumnCount = 1; - string formula = formulaOrArray?.ToString() ?? string.Empty; - object? cellValue = valueOrArray; - - // Only return actual formulas (starting with =), not values - result.Formulas.Add([formula.StartsWith('=') ? formula : string.Empty]); - result.Values.Add([cellValue]); - - // ERROR CODE DETECTION: Single cell error - if (cellValue is int errorCode && errorCode < 0) - { - result.CellErrors.Add(new RangeCellError - { - CellAddress = range.Address, - ErrorCode = errorCode, - ErrorMessage = MapErrorCodeToMessage(errorCode) - }); - } - } - - result.Success = true; - return result; - } - catch (System.Runtime.InteropServices.COMException comEx) when (comEx.HResult == unchecked((int)0x8007000E)) - { - // E_OUTOFMEMORY - Excel's misleading error for sheet/range/session issues - throw new InvalidOperationException($"Cannot read formulas from range '{rangeAddress}' on sheet '{sheetName}': {comEx.Message}", comEx); - } - finally - { - ComUtilities.Release(ref range); - } - }); - } - - /// <summary> - /// Maps Excel error codes to human-readable error messages - /// </summary> - private static string MapErrorCodeToMessage(int errorCode) => - errorCode switch - { - -2146826288 => "#NULL! - Invalid intersection of ranges", - -2147483648 => "#DIV/0! - Division by zero", - -2146826259 => "#VALUE! - Wrong type of argument", - -2146826246 => "#REF! - Invalid cell reference", - -2146826252 => "#NUM! - Invalid numeric value", - -2142019887 => "#N/A - Value not available", - _ => $"#ERROR! - Unknown error code {errorCode}" - }; - - /// <summary> - /// Converts 1-based column index to Excel column letter (1=A, 26=Z, 27=AA) - /// </summary> - private static string GetColumnLetter(int columnIndex) - { - string columnName = string.Empty; - while (columnIndex > 0) - { - columnIndex--; - columnName = Convert.ToChar('A' + (columnIndex % 26)) + columnName; - columnIndex /= 26; - } - return columnName; - } - - /// <inheritdoc /> - public OperationResult SetFormulas(IExcelBatch batch, string sheetName, string rangeAddress, List<List<string>>? formulas = null, string? formulasFile = null) - { - // Resolve formulas from inline parameter or file - var resolvedFormulas = ParameterTransforms.ResolveFormulasOrFile(formulas, formulasFile); - - var result = new OperationResult { FilePath = batch.WorkbookPath, Action = "set-formulas" }; - - return batch.Execute((ctx, ct) => - { - dynamic? range = null; - int originalCalculation = -1; // xlCalculationAutomatic = -4105, xlCalculationManual = -4135 - bool calculationChanged = false; - - try - { - range = RangeHelpers.ResolveRange(ctx.Book, sheetName, rangeAddress, out string? specificError); - if (range == null) - { - throw new InvalidOperationException(specificError ?? RangeHelpers.GetResolveError(sheetName, rangeAddress)); - } - - // CRITICAL: Temporarily disable automatic calculation to prevent Excel from - // hanging when formulas reference Data Model/DAX query tables or complex calculations. - // Without this, setting formulas that trigger recalculation can block the COM interface. - originalCalculation = (int)ctx.App.Calculation; - if (originalCalculation != -4135) // xlCalculationManual - { - ctx.App.Calculation = (Excel.XlCalculation)(-4135); // xlCalculationManual - calculationChanged = true; - } - - // Convert List<List<string>> to 2D array - // Excel COM requires 1-based arrays for multi-cell ranges - int rows = resolvedFormulas.Count; - int cols = resolvedFormulas.Count > 0 ? resolvedFormulas[0].Count : 0; - - if (rows > 0 && cols > 0) - { - // Create 1-based array for Excel COM compatibility - object[,] arrayFormulas = (object[,])Array.CreateInstance(typeof(object), [rows, cols], [1, 1]); - - for (int r = 1; r <= rows; r++) - { - for (int c = 1; c <= cols; c++) - { - // Convert JsonElement to proper C# type for COM interop - // MCP framework deserializes JSON to JsonElement, not primitives - arrayFormulas[r, c] = RangeHelpers.ConvertToCellValue(resolvedFormulas[r - 1][c - 1]); - } - } - - // Use Formula2 (modern) instead of Formula (legacy) to prevent Excel from - // injecting the @ implicit intersection operator in table cells, which causes - // #FIELD! errors with custom functions that return entity cards. - range.Formula2 = arrayFormulas; - } - - result.Success = true; - return result; - } - catch (System.Runtime.InteropServices.COMException comEx) when (comEx.HResult == unchecked((int)0x8007000E)) - { - // E_OUTOFMEMORY - Excel's misleading error for sheet/range/session issues - throw new InvalidOperationException($"Cannot write formulas to range '{rangeAddress}' on sheet '{sheetName}': {comEx.Message}", comEx); - } - finally - { - // Restore original calculation mode - if (calculationChanged && originalCalculation != -1) - { - try - { - ctx.App.Calculation = (Excel.XlCalculation)originalCalculation; - } - catch (System.Runtime.InteropServices.COMException) - { - // Ignore errors restoring calculation mode - not critical - } - } - ComUtilities.Release(ref range); - } - }); - } -} - - - diff --git a/src/ExcelMcp.Core/Commands/Range/RangeCommands.Hyperlinks.cs b/src/ExcelMcp.Core/Commands/Range/RangeCommands.Hyperlinks.cs deleted file mode 100644 index 3d9e29cb..00000000 --- a/src/ExcelMcp.Core/Commands/Range/RangeCommands.Hyperlinks.cs +++ /dev/null @@ -1,235 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; -using Excel = Microsoft.Office.Interop.Excel; - - -namespace Sbroenne.ExcelMcp.Core.Commands.Range; - -/// <summary> -/// Range hyperlink operations (add, remove, list, get) -/// </summary> -public partial class RangeCommands -{ - /// <inheritdoc /> - public OperationResult AddHyperlink(IExcelBatch batch, string sheetName, string cellAddress, string url, string? displayText = null, string? tooltip = null) - { - var result = new OperationResult { FilePath = batch.WorkbookPath, Action = "add-hyperlink" }; - - return batch.Execute((ctx, ct) => - { - Excel.Worksheet? sheet = null; - dynamic? range = null; - dynamic? hyperlinks = null; - dynamic? hyperlink = null; - try - { - sheet = ComUtilities.FindSheet(ctx.Book, sheetName); - if (sheet == null) - { - throw new InvalidOperationException($"Sheet '{sheetName}' not found."); - } - - range = sheet.Range[cellAddress]; - hyperlinks = sheet.Hyperlinks; - - // Resolve URL - full path for file links - string address = url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || - url.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || - url.StartsWith("ftp://", StringComparison.OrdinalIgnoreCase) || - url.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase) - ? url - : Path.GetFullPath(url); - - hyperlink = hyperlinks.Add( - Anchor: range, - Address: address, - SubAddress: Type.Missing, - ScreenTip: tooltip ?? Type.Missing, - TextToDisplay: displayText ?? Type.Missing - ); - - result.Success = true; - return result; - } - finally - { - ComUtilities.Release(ref hyperlink); - ComUtilities.Release(ref hyperlinks); - ComUtilities.Release(ref range); - ComUtilities.Release(ref sheet); - } - }); - } - - /// <inheritdoc /> - public OperationResult RemoveHyperlink(IExcelBatch batch, string sheetName, string rangeAddress) - { - var result = new OperationResult { FilePath = batch.WorkbookPath, Action = "remove-hyperlink" }; - - return batch.Execute((ctx, ct) => - { - dynamic? range = null; - dynamic? hyperlinks = null; - try - { - range = RangeHelpers.ResolveRange(ctx.Book, sheetName, rangeAddress, out string? specificError); - if (range == null) - { - throw new InvalidOperationException(specificError ?? RangeHelpers.GetResolveError(sheetName, rangeAddress)); - } - - hyperlinks = range.Hyperlinks; - int count = hyperlinks.Count; - - // Delete all hyperlinks in the range - for (int i = count; i >= 1; i--) - { - dynamic? hl = null; - try - { - hl = hyperlinks.Item(i); - hl.Delete(); - } - finally - { - ComUtilities.Release(ref hl); - } - } - - result.Success = true; - return result; - } - finally - { - ComUtilities.Release(ref hyperlinks); - ComUtilities.Release(ref range); - } - }); - } - - /// <inheritdoc /> - public RangeHyperlinkResult ListHyperlinks(IExcelBatch batch, string sheetName) - { - var result = new RangeHyperlinkResult - { - FilePath = batch.WorkbookPath, - SheetName = sheetName - }; - - return batch.Execute((ctx, ct) => - { - Excel.Worksheet? sheet = null; - dynamic? hyperlinks = null; - try - { - sheet = ComUtilities.FindSheet(ctx.Book, sheetName); - if (sheet == null) - { - throw new InvalidOperationException($"Sheet '{sheetName}' not found."); - } - - hyperlinks = sheet.Hyperlinks; - int count = hyperlinks.Count; - - for (int i = 1; i <= count; i++) - { - dynamic? hyperlink = null; - dynamic? range = null; - try - { - hyperlink = hyperlinks.Item(i); - range = hyperlink.Range; - - result.Hyperlinks.Add(new HyperlinkInfo - { - CellAddress = range.Address[false, false], - Address = hyperlink.Address ?? string.Empty, - DisplayText = hyperlink.TextToDisplay ?? string.Empty, - ScreenTip = hyperlink.ScreenTip - }); - } - finally - { - ComUtilities.Release(ref range); - ComUtilities.Release(ref hyperlink); - } - } - - result.Success = true; - return result; - } - finally - { - ComUtilities.Release(ref hyperlinks); - ComUtilities.Release(ref sheet); - } - }); - } - - /// <inheritdoc /> - public RangeHyperlinkResult GetHyperlink(IExcelBatch batch, string sheetName, string cellAddress) - { - var result = new RangeHyperlinkResult - { - FilePath = batch.WorkbookPath, - SheetName = sheetName, - RangeAddress = cellAddress - }; - - return batch.Execute((ctx, ct) => - { - Excel.Worksheet? sheet = null; - dynamic? range = null; - dynamic? hyperlinks = null; - try - { - sheet = ComUtilities.FindSheet(ctx.Book, sheetName); - if (sheet == null) - { - throw new InvalidOperationException($"Sheet '{sheetName}' not found."); - } - - range = sheet.Range[cellAddress]; - hyperlinks = range.Hyperlinks; - - int count = hyperlinks.Count; - if (count > 0) - { - dynamic? hyperlink = null; - try - { - hyperlink = hyperlinks.Item(1); // Get first hyperlink in cell - - result.Hyperlinks.Add(new HyperlinkInfo - { - CellAddress = cellAddress, - Address = hyperlink.Address ?? string.Empty, - SubAddress = hyperlink.SubAddress, - DisplayText = hyperlink.TextToDisplay ?? string.Empty, - ScreenTip = hyperlink.ScreenTip, - IsInternal = string.IsNullOrEmpty(hyperlink.Address) - }); - } - finally - { - ComUtilities.Release(ref hyperlink); - } - } - - result.Success = true; - return result; - } - finally - { - ComUtilities.Release(ref hyperlinks); - ComUtilities.Release(ref range); - ComUtilities.Release(ref sheet); - } - }); - } - -} - - - diff --git a/src/ExcelMcp.Core/Commands/Range/RangeCommands.NumberFormat.cs b/src/ExcelMcp.Core/Commands/Range/RangeCommands.NumberFormat.cs deleted file mode 100644 index 59a5030a..00000000 --- a/src/ExcelMcp.Core/Commands/Range/RangeCommands.NumberFormat.cs +++ /dev/null @@ -1,249 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; -using Sbroenne.ExcelMcp.Core.Utilities; - -namespace Sbroenne.ExcelMcp.Core.Commands.Range; - -/// <summary> -/// RangeCommands partial class - Number formatting operations -/// </summary> -public partial class RangeCommands -{ - // === NUMBER FORMAT OPERATIONS === - - /// <inheritdoc /> - public RangeNumberFormatResult GetNumberFormats(IExcelBatch batch, string sheetName, string rangeAddress) - { - var result = new RangeNumberFormatResult - { - FilePath = batch.WorkbookPath, - SheetName = sheetName, - RangeAddress = rangeAddress - }; - - return batch.Execute((ctx, ct) => - { - dynamic? range = null; - try - { - range = RangeHelpers.ResolveRange(ctx.Book, sheetName, rangeAddress, out string? specificError); - if (range == null) - { - throw new InvalidOperationException(specificError ?? RangeHelpers.GetResolveError(sheetName, rangeAddress)); - } - - // Get actual address from Excel - result.RangeAddress = range.Address; - - // Get number formats - Excel COM behavior: - // - Single cell: returns string - // - Multiple cells, all same format: returns string - // - Multiple cells, mixed formats: returns DBNull (must read cell-by-cell) - object numberFormats = range.NumberFormat; - - // Get dimensions - int rowCount = Convert.ToInt32(range.Rows.Count); - int columnCount = Convert.ToInt32(range.Columns.Count); - - result.RowCount = rowCount; - result.ColumnCount = columnCount; - - // Check if we have mixed formats (DBNull or null) - if (numberFormats is null or DBNull) - { - // Mixed formats - must read cell-by-cell - dynamic? cells = null; - try - { - cells = range.Cells; - for (int row = 1; row <= rowCount; row++) - { - var rowList = new List<string>(); - for (int col = 1; col <= columnCount; col++) - { - dynamic? cell = null; - try - { - cell = cells[row, col]; - var format = cell.NumberFormat?.ToString() ?? "General"; - rowList.Add(format); - } - finally - { - ComUtilities.Release(ref cell); - } - } - result.Formats.Add(rowList); - } - } - finally - { - ComUtilities.Release(ref cells); - } - } - else if (numberFormats is string formatStr) - { - // All cells have same format - for (int row = 0; row < rowCount; row++) - { - var rowList = new List<string>(); - for (int col = 0; col < columnCount; col++) - { - rowList.Add(formatStr); - } - result.Formats.Add(rowList); - } - } - else - { - // Should be a 2D array (rare case). COM arrays are 1-based. - object[,] formats = (object[,])numberFormats; - for (int row = 1; row <= rowCount; row++) - { - var rowList = new List<string>(); - for (int col = 1; col <= columnCount; col++) - { - var format = formats[row, col]?.ToString() ?? "General"; - rowList.Add(format); - } - result.Formats.Add(rowList); - } - } - - result.Success = true; - return result; - } - finally - { - ComUtilities.Release(ref range); - } - }); - } - - /// <inheritdoc /> - public OperationResult SetNumberFormat(IExcelBatch batch, string sheetName, string rangeAddress, string formatCode) - { - var result = new OperationResult - { - FilePath = batch.WorkbookPath, - Action = "set-number-format" - }; - - return batch.Execute((ctx, ct) => - { - dynamic? range = null; - try - { - range = RangeHelpers.ResolveRange(ctx.Book, sheetName, rangeAddress, out string? specificError); - if (range == null) - { - throw new InvalidOperationException(specificError ?? RangeHelpers.GetResolveError(sheetName, rangeAddress)); - } - - // Translate US format codes to locale-specific codes - var translatedFormat = ctx.FormatTranslator.TranslateToLocale(formatCode); - range.NumberFormat = translatedFormat; - - result.Success = true; - return result; - } - finally - { - ComUtilities.Release(ref range); - } - }); - } - - /// <inheritdoc /> - public OperationResult SetNumberFormats(IExcelBatch batch, string sheetName, string rangeAddress, List<List<string>>? formats = null, string? formatsFile = null) - { - // Resolve formats from inline parameter or file - var resolvedFormats = ParameterTransforms.ResolveFormulasOrFile(formats, formatsFile, "formats"); - - var result = new OperationResult - { - FilePath = batch.WorkbookPath, - Action = "set-number-formats" - }; - - return batch.Execute((ctx, ct) => - { - dynamic? range = null; - try - { - range = RangeHelpers.ResolveRange(ctx.Book, sheetName, rangeAddress, out string? specificError); - if (range == null) - { - throw new InvalidOperationException(specificError ?? RangeHelpers.GetResolveError(sheetName, rangeAddress)); - } - - int rowCount = Convert.ToInt32(range.Rows.Count); - int columnCount = Convert.ToInt32(range.Columns.Count); - - // Validate dimensions match - if (resolvedFormats.Count != rowCount) - { - throw new ArgumentException($"Format array row count ({resolvedFormats.Count}) doesn't match range row count ({rowCount})", nameof(formats)); - } - - for (int i = 0; i < resolvedFormats.Count; i++) - { - if (resolvedFormats[i].Count != columnCount) - { - throw new ArgumentException($"Format array row {i + 1} column count ({resolvedFormats[i].Count}) doesn't match range column count ({columnCount})", nameof(formats)); - } - } - - // Translate all format codes to locale-specific codes - var translator = ctx.FormatTranslator; - - // If single row or column, can't use 2D array - must set cell by cell - if (rowCount == 1 || columnCount == 1) - { - for (int row = 1; row <= rowCount; row++) - { - for (int col = 1; col <= columnCount; col++) - { - dynamic? cell = null; - try - { - cell = range.Cells[row, col]; - cell.NumberFormat = translator.TranslateToLocale(resolvedFormats[row - 1][col - 1]); - } - finally - { - ComUtilities.Release(ref cell); - } - } - } - } - else - { - // For multi-row, multi-column ranges, Excel COM expects 1-based 2D array - object[,] formatArray = new object[rowCount, columnCount]; - for (int row = 0; row < rowCount; row++) - { - for (int col = 0; col < columnCount; col++) - { - formatArray[row, col] = translator.TranslateToLocale(resolvedFormats[row][col]); - } - } - - // Set number formats via 2D array - range.NumberFormat = formatArray; - } - - result.Success = true; - return result; - } - finally - { - ComUtilities.Release(ref range); - } - }); - } -} - - - diff --git a/src/ExcelMcp.Core/Commands/Range/RangeCommands.Search.cs b/src/ExcelMcp.Core/Commands/Range/RangeCommands.Search.cs deleted file mode 100644 index e69106b5..00000000 --- a/src/ExcelMcp.Core/Commands/Range/RangeCommands.Search.cs +++ /dev/null @@ -1,193 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; - - -namespace Sbroenne.ExcelMcp.Core.Commands.Range; - -/// <summary> -/// Range search and sort operations (find, replace, sort) -/// </summary> -public partial class RangeCommands -{ - // === FIND/REPLACE OPERATIONS === - - /// <summary> - /// Finds all cells matching criteria in range - /// Excel COM: Range.Find() - /// </summary> - public RangeFindResult Find(IExcelBatch batch, string sheetName, string rangeAddress, string searchValue, FindOptions findOptions) - { - var result = new RangeFindResult - { - FilePath = batch.WorkbookPath, - SheetName = sheetName, - RangeAddress = rangeAddress, - SearchValue = searchValue - }; - - return batch.Execute((ctx, ct) => - { - dynamic? range = null; - dynamic? foundCell = null; - try - { - range = RangeHelpers.ResolveRange(ctx.Book, sheetName, rangeAddress, out string? specificError); - if (range == null) - { - throw new InvalidOperationException(specificError ?? RangeHelpers.GetResolveError(sheetName, rangeAddress)); - } - - // Excel COM constants - int lookIn = findOptions.SearchFormulas && findOptions.SearchValues ? -4163 : // xlValues - findOptions.SearchFormulas ? -4123 : -4163; // xlFormulas : xlValues - int lookAt = findOptions.MatchEntireCell ? 1 : 2; // xlWhole : xlPart - - foundCell = range.Find( - What: searchValue, - LookIn: lookIn, - LookAt: lookAt, - SearchOrder: 1, // xlByRows - SearchDirection: 1, // xlNext - MatchCase: findOptions.MatchCase - ); - - if (foundCell != null) - { - string firstAddress = foundCell.Address; - do - { - result.MatchingCells.Add(new RangeCell - { - Address = foundCell.Address, - Row = foundCell.Row, - Column = foundCell.Column, - Value = foundCell.Value2 - }); - - foundCell = range.FindNext(foundCell); - } while (foundCell != null && foundCell.Address != firstAddress); - } - - result.Success = true; - return result; - } - finally - { - ComUtilities.Release(ref foundCell); - ComUtilities.Release(ref range); - } - }); - } - - /// <inheritdoc /> - public OperationResult Replace(IExcelBatch batch, string sheetName, string rangeAddress, string findValue, string replaceValue, ReplaceOptions replaceOptions) - { - return batch.Execute((ctx, ct) => - { - dynamic? range = null; - try - { - range = RangeHelpers.ResolveRange(ctx.Book, sheetName, rangeAddress, out string? specificError); - if (range == null) - { - throw new InvalidOperationException(specificError ?? RangeHelpers.GetResolveError(sheetName, rangeAddress)); - } - - // Excel COM constants - int lookIn = replaceOptions.SearchFormulas && replaceOptions.SearchValues ? -4163 : // xlValues - replaceOptions.SearchFormulas ? -4123 : -4163; // xlFormulas : xlValues - int lookAt = replaceOptions.MatchEntireCell ? 1 : 2; // xlWhole : xlPart - - range.Replace( - What: findValue, - Replacement: replaceValue, - LookAt: lookAt, - SearchOrder: 1, // xlByRows - MatchCase: replaceOptions.MatchCase, - MatchByte: false - ); - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref range); - } - }); - } - - // === SORT OPERATIONS === - - /// <inheritdoc /> - public OperationResult Sort(IExcelBatch batch, string sheetName, string rangeAddress, List<SortColumn> sortColumns, bool hasHeaders = true) - { - return batch.Execute((ctx, ct) => - { - dynamic? range = null; - dynamic? key1 = null; - dynamic? key2 = null; - dynamic? key3 = null; - try - { - range = RangeHelpers.ResolveRange(ctx.Book, sheetName, rangeAddress, out string? specificError); - if (range == null) - { - throw new InvalidOperationException(specificError ?? RangeHelpers.GetResolveError(sheetName, rangeAddress)); - } - - if (sortColumns.Count == 0) - { - throw new ArgumentException("At least one sort column must be specified", nameof(sortColumns)); - } - - // Excel COM supports up to 3 sort keys - if (sortColumns.Count > 3) - { - throw new ArgumentException("Excel COM API supports maximum 3 sort columns", nameof(sortColumns)); - } - - // Get sort key ranges - key1 = sortColumns.Count >= 1 ? range.Columns[sortColumns[0].ColumnIndex] : Type.Missing; - key2 = sortColumns.Count >= 2 ? range.Columns[sortColumns[1].ColumnIndex] : Type.Missing; - key3 = sortColumns.Count >= 3 ? range.Columns[sortColumns[2].ColumnIndex] : Type.Missing; - - // Excel COM constants - int order1 = sortColumns[0].Ascending ? 1 : 2; // xlAscending : xlDescending - int order2 = sortColumns.Count >= 2 ? (sortColumns[1].Ascending ? 1 : 2) : 1; - int order3 = sortColumns.Count >= 3 ? (sortColumns[2].Ascending ? 1 : 2) : 1; - int header = hasHeaders ? 1 : 2; // xlYes : xlNo - - // Call Range.Sort method - range.Sort( - Key1: key1, - Order1: order1, - Key2: key2, - Order2: order2, - Key3: key3, - Order3: order3, - Header: header, - OrderCustom: 1, - MatchCase: false, - Orientation: 1, // xlTopToBottom - SortMethod: 1 // xlPinYin - ); - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref key3); - ComUtilities.Release(ref key2); - ComUtilities.Release(ref key1); - ComUtilities.Release(ref range); - } - }); - } - - // === NATIVE EXCEL COM OPERATIONS === - -} - - - diff --git a/src/ExcelMcp.Core/Commands/Range/RangeCommands.Sizing.cs b/src/ExcelMcp.Core/Commands/Range/RangeCommands.Sizing.cs deleted file mode 100644 index 27c7296b..00000000 --- a/src/ExcelMcp.Core/Commands/Range/RangeCommands.Sizing.cs +++ /dev/null @@ -1,98 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.Range; - -/// <summary> -/// Explicit sizing operations for Excel ranges (partial class) -/// Sets specific column widths and row heights. -/// </summary> -public partial class RangeCommands -{ - /// <inheritdoc /> - public OperationResult SetColumnWidth( - IExcelBatch batch, - string sheetName, - string rangeAddress, - double columnWidth) - { - if (columnWidth < 0.25 || columnWidth > 409) - { - throw new ArgumentException("columnWidth must be between 0.25 and 409 points", nameof(columnWidth)); - } - - return batch.Execute((ctx, ct) => - { - dynamic? sheet = null; - dynamic? range = null; - dynamic? columns = null; - - try - { - // Get sheet - sheet = string.IsNullOrEmpty(sheetName) - ? ctx.Book.ActiveSheet - : ctx.Book.Worksheets[sheetName]; - - // Get range and its columns - range = sheet.Range[rangeAddress]; - columns = range.Columns; - - // Set column width for all columns in range - columns.ColumnWidth = columnWidth; - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref columns!); - ComUtilities.Release(ref range!); - ComUtilities.Release(ref sheet!); - } - }); - } - - /// <inheritdoc /> - public OperationResult SetRowHeight( - IExcelBatch batch, - string sheetName, - string rangeAddress, - double rowHeight) - { - if (rowHeight < 0 || rowHeight > 409) - { - throw new ArgumentException("rowHeight must be between 0 and 409 points", nameof(rowHeight)); - } - - return batch.Execute((ctx, ct) => - { - dynamic? sheet = null; - dynamic? range = null; - dynamic? rows = null; - - try - { - // Get sheet - sheet = string.IsNullOrEmpty(sheetName) - ? ctx.Book.ActiveSheet - : ctx.Book.Worksheets[sheetName]; - - // Get range and its rows - range = sheet.Range[rangeAddress]; - rows = range.Rows; - - // Set row height for all rows in range - rows.RowHeight = rowHeight; - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref rows!); - ComUtilities.Release(ref range!); - ComUtilities.Release(ref sheet!); - } - }); - } -} diff --git a/src/ExcelMcp.Core/Commands/Range/RangeCommands.Validation.cs b/src/ExcelMcp.Core/Commands/Range/RangeCommands.Validation.cs deleted file mode 100644 index 108e76b9..00000000 --- a/src/ExcelMcp.Core/Commands/Range/RangeCommands.Validation.cs +++ /dev/null @@ -1,322 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.Range; - -/// <summary> -/// Data validation operations for Excel ranges (partial class) -/// </summary> -public partial class RangeCommands -{ - /// <inheritdoc /> - public OperationResult ValidateRange( - IExcelBatch batch, - string sheetName, - string rangeAddress, - string validationType, - string? validationOperator, - string? formula1, - string? formula2, - bool? showInputMessage, - string? inputTitle, - string? inputMessage, - bool? showErrorAlert, - string? errorStyle, - string? errorTitle, - string? errorMessage, - bool? ignoreBlank, - bool? showDropdown) - { - return batch.Execute((ctx, ct) => - { - dynamic? sheet = null; - dynamic? range = null; - dynamic? validation = null; - - try - { - // Get sheet - sheet = string.IsNullOrEmpty(sheetName) - ? ctx.Book.ActiveSheet - : ctx.Book.Worksheets[sheetName]; - - // Get range - range = sheet.Range[rangeAddress]; - - // Get validation object - validation = range.Validation; - - // Delete existing validation - validation.Delete(); - - // Parse validation type - var xlType = ParseValidationType(validationType); - var xlOperator = ParseValidationOperator(validationOperator ?? "between"); - var xlAlertStyle = ParseErrorStyle(errorStyle ?? "stop"); - - // Add validation - validation.Add( - Type: xlType, - AlertStyle: xlAlertStyle, - Operator: xlOperator, - Formula1: formula1 ?? "", - Formula2: formula2 ?? ""); - - // Configure input message - if (showInputMessage is true) - { - validation.ShowInput = true; // MUST set ShowInput=true BEFORE setting title/message - validation.InputTitle = inputTitle ?? ""; - validation.InputMessage = inputMessage ?? ""; - } - - // Configure error alert - if (showErrorAlert is true) - { - validation.ErrorTitle = errorTitle ?? ""; - validation.ErrorMessage = errorMessage ?? ""; - validation.ShowError = true; - } - - // Configure additional options - if (ignoreBlank != null) - { - validation.IgnoreBlank = ignoreBlank.Value; - } - - if (showDropdown != null && validationType.Equals("list", StringComparison.OrdinalIgnoreCase)) - { - validation.InCellDropdown = showDropdown.Value; - } - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref validation!); - ComUtilities.Release(ref range!); - ComUtilities.Release(ref sheet!); - } - }); - } - - private static int ParseValidationType(string type) - { - return type.ToLowerInvariant() switch - { - "any" => 0, // xlValidateInputOnly - "whole" => 1, // xlValidateWholeNumber - "decimal" => 2, // xlValidateDecimal - "list" => 3, // xlValidateList - "date" => 4, // xlValidateDate - "time" => 5, // xlValidateTime - "textlength" => 6, // xlValidateTextLength - "custom" => 7, // xlValidateCustom - _ => throw new ArgumentException($"Invalid validation type: {type}") - }; - } - - private static int ParseValidationOperator(string op) - { - return op.ToLowerInvariant() switch - { - "between" => 1, // xlBetween - "notbetween" => 2, // xlNotBetween - "equal" => 3, // xlEqual - "notequal" => 4, // xlNotEqual - "greaterthan" => 5, // xlGreater - "lessthan" => 6, // xlLess - "greaterthanorequal" => 7, // xlGreaterEqual - "lessthanorequal" => 8, // xlLessEqual - _ => throw new ArgumentException($"Invalid validation operator: {op}") - }; - } - - private static int ParseErrorStyle(string style) - { - return style.ToLowerInvariant() switch - { - "stop" => 1, // xlValidAlertStop - "warning" => 2, // xlValidAlertWarning - "information" => 3, // xlValidAlertInformation - _ => throw new ArgumentException($"Invalid error style: {style}") - }; - } - - /// <inheritdoc /> - public RangeValidationResult GetValidation( - IExcelBatch batch, - string sheetName, - string rangeAddress) - { - return batch.Execute((ctx, ct) => - { - dynamic? sheet = null; - dynamic? range = null; - dynamic? validation = null; - - try - { - // Get sheet - sheet = string.IsNullOrEmpty(sheetName) - ? ctx.Book.ActiveSheet - : ctx.Book.Worksheets[sheetName]; - - // Get range - range = sheet.Range[rangeAddress]; - - // Try to get validation - validation = range.Validation; - - // Check if validation exists - var hasValidation = true; - try - { - var testType = validation.Type; - } - catch (System.Runtime.InteropServices.COMException) - { - hasValidation = false; - } - - if (!hasValidation) - { - return new RangeValidationResult - { - Success = true, - FilePath = batch.WorkbookPath, - SheetName = sheetName, - RangeAddress = rangeAddress, - HasValidation = false - }; - } - - // Read all validation properties into local variables first - // This ensures we're not affected by any COM state changes during object initialization - var validationType = GetValidationTypeName(validation.Type); - var validationOperator = GetValidationOperatorName(validation.Operator); - var formula1 = validation.Formula1?.ToString() ?? string.Empty; - var formula2 = validation.Formula2?.ToString() ?? string.Empty; - var ignoreBlank = validation.IgnoreBlank ?? true; - var showInputMessage = validation.ShowInput ?? false; - var inputTitle = validation.InputTitle?.ToString() ?? string.Empty; - var inputMessage = validation.InputMessage?.ToString() ?? string.Empty; - var showErrorAlert = validation.ShowError ?? true; - var errorStyle = GetErrorStyleName(validation.AlertStyle); - var errorTitle = validation.ErrorTitle?.ToString() ?? string.Empty; - var validationErrorMessage = validation.ErrorMessage?.ToString() ?? string.Empty; - - return new RangeValidationResult - { - Success = true, - FilePath = batch.WorkbookPath, - SheetName = sheetName, - RangeAddress = rangeAddress, - HasValidation = true, - ValidationType = validationType, - ValidationOperator = validationOperator, - Formula1 = formula1, - Formula2 = formula2, - IgnoreBlank = ignoreBlank, - ShowInputMessage = showInputMessage, - InputTitle = inputTitle, - InputMessage = inputMessage, - ShowErrorAlert = showErrorAlert, - ErrorStyle = errorStyle, - ErrorTitle = errorTitle, - ValidationErrorMessage = validationErrorMessage - }; - } - finally - { - ComUtilities.Release(ref validation!); - ComUtilities.Release(ref range!); - ComUtilities.Release(ref sheet!); - } - }); - } - - /// <inheritdoc /> - public OperationResult RemoveValidation( - IExcelBatch batch, - string sheetName, - string rangeAddress) - { - return batch.Execute((ctx, ct) => - { - dynamic? sheet = null; - dynamic? range = null; - dynamic? validation = null; - - try - { - // Get sheet - sheet = string.IsNullOrEmpty(sheetName) - ? ctx.Book.ActiveSheet - : ctx.Book.Worksheets[sheetName]; - - // Get range - range = sheet.Range[rangeAddress]; - - // Get validation and delete - validation = range.Validation; - validation.Delete(); - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref validation!); - ComUtilities.Release(ref range!); - ComUtilities.Release(ref sheet!); - } - }); - } - - private static string GetValidationTypeName(int type) - { - return type switch - { - 0 => "any", - 1 => "whole", - 2 => "decimal", - 3 => "list", - 4 => "date", - 5 => "time", - 6 => "textlength", - 7 => "custom", - _ => "unknown" - }; - } - - private static string GetValidationOperatorName(int op) - { - return op switch - { - 1 => "between", - 2 => "notbetween", - 3 => "equal", - 4 => "notequal", - 5 => "greaterthan", - 6 => "lessthan", - 7 => "greaterthanorequal", - 8 => "lessthanorequal", - _ => "unknown" - }; - } - - private static string GetErrorStyleName(int style) - { - return style switch - { - 1 => "stop", - 2 => "warning", - 3 => "information", - _ => "unknown" - }; - } -} - - - diff --git a/src/ExcelMcp.Core/Commands/Range/RangeCommands.Values.cs b/src/ExcelMcp.Core/Commands/Range/RangeCommands.Values.cs deleted file mode 100644 index e5a206bb..00000000 --- a/src/ExcelMcp.Core/Commands/Range/RangeCommands.Values.cs +++ /dev/null @@ -1,219 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; -using Sbroenne.ExcelMcp.Core.Utilities; -using Excel = Microsoft.Office.Interop.Excel; - - -namespace Sbroenne.ExcelMcp.Core.Commands.Range; - -/// <summary> -/// Range value operations (get/set values as 2D arrays) -/// </summary> -public partial class RangeCommands -{ - /// <inheritdoc /> - public RangeValueResult GetValues(IExcelBatch batch, string sheetName, string rangeAddress) - { - var result = new RangeValueResult - { - FilePath = batch.WorkbookPath, - SheetName = sheetName, - RangeAddress = rangeAddress - }; - - return batch.Execute((ctx, ct) => - { - dynamic? range = null; - try - { - range = RangeHelpers.ResolveRange(ctx.Book, sheetName, rangeAddress, out string? specificError); - if (range == null) - { - throw new InvalidOperationException(specificError ?? RangeHelpers.GetResolveError(sheetName, rangeAddress)); - } - - // Get actual address from Excel - result.RangeAddress = range.Address; - - // Get values as 2D array - handle single cell case - object valueOrArray = range.Value2; - - if (valueOrArray is object[,] values) - { - // Multi-cell range - process as 2D array - result.RowCount = values.GetLength(0); - result.ColumnCount = values.GetLength(1); - - for (int r = 1; r <= result.RowCount; r++) - { - var row = new List<object?>(); - for (int c = 1; c <= result.ColumnCount; c++) - { - row.Add(values[r, c]); - } - result.Values.Add(row); - } - } - else - { - // Single cell - wrap value in 1x1 array - result.RowCount = 1; - result.ColumnCount = 1; - result.Values.Add([valueOrArray]); - } - - result.Success = true; - return result; - } - catch (System.Runtime.InteropServices.COMException comEx) when (comEx.HResult == unchecked((int)0x8007000E)) - { - // E_OUTOFMEMORY - Excel's misleading error for sheet/range/session issues - throw new InvalidOperationException($"Cannot read range '{rangeAddress}' on sheet '{sheetName}': {comEx.Message}", comEx); - } - finally - { - ComUtilities.Release(ref range); - } - }); - } - - /// <inheritdoc /> - public OperationResult SetValues(IExcelBatch batch, string sheetName, string rangeAddress, List<List<object?>>? values = null, string? valuesFile = null) - { - // Resolve values from inline parameter or file - var resolvedValues = ParameterTransforms.ResolveValuesOrFile(values, valuesFile); - - // SMART FORMULA DETECTION: Check if any value starts with "=" and auto-route to SetFormulas - bool hasFormulas = DetectFormulas(resolvedValues, out var detectedFormulas); - if (hasFormulas) - { - // Detected formulas - convert to proper formula format and use SetFormulas - var result = new OperationResult { FilePath = batch.WorkbookPath, Action = "set-values" }; - - // Call SetFormulas internally to apply detected formulas - var formulaResult = SetFormulas(batch, sheetName, rangeAddress, detectedFormulas); - - // Copy result data and add detection message - result.Success = formulaResult.Success; - result.ErrorMessage = formulaResult.ErrorMessage; - if (result.Success && string.IsNullOrEmpty(result.Message)) - { - result.Message = $"Formula detected: {detectedFormulas.Sum(row => row.Count(f => !string.IsNullOrEmpty(f)))} formula(s) applied via set-formulas"; - } - return result; - } - - var setResult = new OperationResult { FilePath = batch.WorkbookPath, Action = "set-values" }; - - return batch.Execute((ctx, ct) => - { - dynamic? range = null; - int originalCalculation = -1; // xlCalculationAutomatic = -4105, xlCalculationManual = -4135 - bool calculationChanged = false; - - try - { - range = RangeHelpers.ResolveRange(ctx.Book, sheetName, rangeAddress, out string? specificError); - if (range == null) - { - throw new InvalidOperationException(specificError ?? RangeHelpers.GetResolveError(sheetName, rangeAddress)); - } - - // CRITICAL: Temporarily disable automatic calculation to prevent Excel from - // hanging when changed values trigger dependent formulas that reference Data Model/DAX. - // Without this, setting values can block the COM interface during recalculation. - originalCalculation = (int)ctx.App.Calculation; - if (originalCalculation != -4135) // xlCalculationManual - { - ctx.App.Calculation = (Excel.XlCalculation)(-4135); // xlCalculationManual - calculationChanged = true; - } - - // Convert List<List<object?>> to 2D array - // Excel COM requires 1-based arrays for multi-cell ranges - int rows = resolvedValues.Count; - int cols = resolvedValues.Count > 0 ? resolvedValues[0].Count : 0; - - if (rows > 0 && cols > 0) - { - // Create 1-based array for Excel COM compatibility - object[,] arrayValues = (object[,])Array.CreateInstance(typeof(object), [rows, cols], [1, 1]); - - for (int r = 1; r <= rows; r++) - { - for (int c = 1; c <= cols; c++) - { - // Convert JsonElement to proper C# type for COM interop - // MCP framework deserializes JSON to JsonElement, not primitives - arrayValues[r, c] = RangeHelpers.ConvertToCellValue(resolvedValues[r - 1][c - 1]); - } - } - - range.Value2 = arrayValues; - } - - setResult.Success = true; - return setResult; - } - catch (System.Runtime.InteropServices.COMException comEx) when (comEx.HResult == unchecked((int)0x8007000E)) - { - // E_OUTOFMEMORY - Excel's misleading error for sheet/range/session issues - throw new InvalidOperationException($"Cannot write to range '{rangeAddress}' on sheet '{sheetName}': {comEx.Message}", comEx); - } - finally - { - // Restore original calculation mode - if (calculationChanged && originalCalculation != -1) - { - try - { - ctx.App.Calculation = (Excel.XlCalculation)originalCalculation; - } - catch (System.Runtime.InteropServices.COMException) - { - // Ignore errors restoring calculation mode - not critical - } - } - ComUtilities.Release(ref range); - } - }); - } - - /// <summary> - /// Detects formulas in value array (strings starting with =) - /// Returns true if any formulas detected, outputs formula array - /// </summary> - private static bool DetectFormulas(List<List<object?>> values, out List<List<string>> detectedFormulas) - { - detectedFormulas = new List<List<string>>(); - bool hasFormulas = false; - - foreach (var row in values) - { - var formulaRow = new List<string>(); - foreach (var value in row) - { - string str = value?.ToString() ?? string.Empty; - - // Detect formula (starts with = but not escaped with ') - if (str.StartsWith('=') && !str.StartsWith("'=", StringComparison.Ordinal)) - { - formulaRow.Add(str); - hasFormulas = true; - } - else - { - // Not a formula - empty string in formula array - formulaRow.Add(string.Empty); - } - } - detectedFormulas.Add(formulaRow); - } - - return hasFormulas; - } -} - - - diff --git a/src/ExcelMcp.Core/Commands/Range/RangeCommands.cs b/src/ExcelMcp.Core/Commands/Range/RangeCommands.cs deleted file mode 100644 index b297d622..00000000 --- a/src/ExcelMcp.Core/Commands/Range/RangeCommands.cs +++ /dev/null @@ -1,30 +0,0 @@ - -namespace Sbroenne.ExcelMcp.Core.Commands.Range; - -/// <summary> -/// Excel range operations implementation - unified API for all range data operations. -/// Single cell is treated as 1x1 range. Named ranges work transparently via rangeAddress parameter. -/// All operations are COM-backed (no data processing in server). -/// Implements IRangeCommands (values/formulas/copy/clear/discovery), -/// IRangeEditCommands (insert/delete/find/replace/sort), -/// IRangeFormatCommands (styling/validation/merge/autofit), -/// and IRangeLinkCommands (hyperlinks/cell protection). -/// </summary> -public partial class RangeCommands : IRangeCommands, IRangeEditCommands, IRangeFormatCommands, IRangeLinkCommands -{ - // This is the main partial class file containing only the class declaration. - // Implementation methods are organized into separate partial files by feature domain: - // - RangeCommands.Values.cs (GetValues, SetValues) - // - RangeCommands.Formulas.cs (GetFormulas, SetFormulas) - // - RangeCommands.Editing.cs (Clear, Copy, Insert, Delete operations) - // - RangeCommands.Search.cs (Find, Replace, Sort) - // - RangeCommands.Discovery.cs (GetUsedRange, GetCurrentRegion, GetRangeInfo) - // - RangeCommands.Hyperlinks.cs (Add, Remove, List, Get hyperlinks) - // - RangeCommands.NumberFormat.cs (Get, Set number formats) - // - RangeCommands.Formatting.cs (SetStyle, GetStyle, FormatRange) - // - RangeCommands.Validation.cs (ValidateRange, GetValidation, RemoveValidation) - // - RangeCommands.AutoFit.cs (AutoFitColumns, AutoFitRows) - // - RangeCommands.Advanced.cs (MergeCells, UnmergeCells, GetMergeInfo, SetCellLock, GetCellLock) -} - - diff --git a/src/ExcelMcp.Core/Commands/Range/RangeHelpers.cs b/src/ExcelMcp.Core/Commands/Range/RangeHelpers.cs deleted file mode 100644 index 09a299be..00000000 --- a/src/ExcelMcp.Core/Commands/Range/RangeHelpers.cs +++ /dev/null @@ -1,245 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; -using Excel = Microsoft.Office.Interop.Excel; - - -namespace Sbroenne.ExcelMcp.Core.Commands.Range; - -/// <summary> -/// Range resolution and helper methods for RangeCommands -/// </summary> -public static class RangeHelpers -{ - /// <summary> - /// Resolves a range address to a Range COM object. - /// Supports both regular ranges (Sheet1!A1:D10) and named ranges. - /// Returns null if resolution fails. - /// </summary> - public static dynamic? ResolveRange(dynamic book, string sheetName, string rangeAddress, out string? specificError) - { - specificError = null; - - // Named range (empty sheetName) - if (string.IsNullOrEmpty(sheetName)) - { - try - { - dynamic names = book.Names; - dynamic name = names.Item(rangeAddress); - return name.RefersToRange; - } - catch (System.Runtime.InteropServices.COMException) - { - specificError = $"Named range '{rangeAddress}' not found."; - return null; - } - } - - // Regular range (sheet + address) - // First check if sheet exists - Excel.Worksheet? sheet = null; - try - { - sheet = ComUtilities.FindSheet(book, sheetName); - if (sheet == null) - { - specificError = $"Sheet '{sheetName}' not found."; - return null; - } - - // Sheet exists, now try to get the range - try - { - return sheet.Range[rangeAddress]; - } - catch (Exception ex) - { - specificError = $"Sheet '{sheetName}' exists, but range '{rangeAddress}' is invalid. " + - $"Error: {ex.Message}. " + - $"Verify the range address format (e.g., 'A1:E10', 'A1', 'A:A')."; - return null; - } - } - finally - { - ComUtilities.Release(ref sheet); - } - } - - /// <summary> - /// Resolves a range address to a Range COM object (backward compatibility). - /// Supports both regular ranges (Sheet1!A1:D10) and named ranges. - /// </summary> - public static dynamic? ResolveRange(dynamic book, string sheetName, string rangeAddress) - { - string? ignoredError; - return ResolveRange(book, sheetName, rangeAddress, out ignoredError); - } - - /// <summary> - /// Gets appropriate error message for range resolution failure - /// </summary> - public static string GetResolveError(string sheetName, string rangeAddress) - { - if (string.IsNullOrEmpty(sheetName)) - { - return $"Named range '{rangeAddress}' not found."; - } - return $"Sheet '{sheetName}' or range '{rangeAddress}' not found."; - } - - /// <summary> - /// Converts a value to a proper Excel cell value, handling System.Text.Json.JsonElement. - /// MCP framework deserializes JSON arrays to JsonElement objects which cannot be marshalled to COM Variant. - /// This helper detects JsonElement and converts to proper C# types before COM assignment. - /// </summary> - /// <param name="value">Value from MCP JSON deserialization or direct C# types</param> - /// <returns>Proper C# type (string, long, double, bool) for COM marshalling</returns> - public static object ConvertToCellValue(object? value) - { - if (value == null) - return string.Empty; - - // Handle System.Text.Json.JsonElement (from MCP JSON deserialization) - if (value is System.Text.Json.JsonElement jsonElement) - { - return jsonElement.ValueKind switch - { - System.Text.Json.JsonValueKind.String => jsonElement.GetString() ?? string.Empty, - System.Text.Json.JsonValueKind.Number => jsonElement.TryGetInt64(out var i64) ? i64 : jsonElement.GetDouble(), - System.Text.Json.JsonValueKind.True => true, - System.Text.Json.JsonValueKind.False => false, - System.Text.Json.JsonValueKind.Null => string.Empty, - _ => jsonElement.ToString() ?? string.Empty - }; - } - - // Already a proper type (from CLI or tests) - return value; - } -} - -/// <summary> -/// Internal helper methods for RangeCommands partial class -/// </summary> -public partial class RangeCommands -{ - /// <summary> - /// Helper for clear operations (resolve range, apply action, release) - /// </summary> - private static OperationResult ClearRange( - IExcelBatch batch, - string sheetName, - string rangeAddress, - string action, - Action<dynamic> clearAction) - { - var result = new OperationResult { FilePath = batch.WorkbookPath, Action = action }; - - return batch.Execute((ctx, ct) => - { - dynamic? range = null; - try - { - range = RangeHelpers.ResolveRange(ctx.Book, sheetName, rangeAddress); - if (range == null) - { - throw new InvalidOperationException(RangeHelpers.GetResolveError(sheetName, rangeAddress)); - } - - clearAction(range); - result.Success = true; - return result; - } - finally - { - ComUtilities.Release(ref range); - } - }); - } - - /// <summary> - /// Helper for copy operations (resolve source + target ranges, apply copy action, release both) - /// </summary> - private static OperationResult CopyRange( - IExcelBatch batch, - string sourceSheet, - string sourceRange, - string targetSheet, - string targetRange, - string action, - Action<dynamic, dynamic> copyAction) - { - var result = new OperationResult { FilePath = batch.WorkbookPath, Action = action }; - - return batch.Execute((ctx, ct) => - { - dynamic? srcRange = null; - dynamic? tgtRange = null; - try - { - srcRange = RangeHelpers.ResolveRange(ctx.Book, sourceSheet, sourceRange, out string? srcError); - if (srcRange == null) - { - throw new InvalidOperationException(srcError ?? RangeHelpers.GetResolveError(sourceSheet, sourceRange)); - } - - tgtRange = RangeHelpers.ResolveRange(ctx.Book, targetSheet, targetRange, out string? tgtError); - if (tgtRange == null) - { - throw new InvalidOperationException(tgtError ?? RangeHelpers.GetResolveError(targetSheet, targetRange)); - } - - copyAction(srcRange, tgtRange); - result.Success = true; - return result; - } - finally - { - ComUtilities.Release(ref srcRange); - ComUtilities.Release(ref tgtRange); - } - }); - } - - /// <summary> - /// Helper for insert/delete row/column operations (resolve range, get EntireRow/EntireColumn, apply action, release) - /// </summary> - private static OperationResult ModifyRowsOrColumns( - IExcelBatch batch, - string sheetName, - string rangeAddress, - string action, - Func<dynamic, dynamic> accessor, - Action<dynamic> operation) - { - var result = new OperationResult { FilePath = batch.WorkbookPath, Action = action }; - - return batch.Execute((ctx, ct) => - { - dynamic? range = null; - dynamic? target = null; - try - { - range = RangeHelpers.ResolveRange(ctx.Book, sheetName, rangeAddress, out string? specificError); - if (range == null) - { - throw new InvalidOperationException(specificError ?? RangeHelpers.GetResolveError(sheetName, rangeAddress)); - } - - target = accessor(range); - operation(target); - result.Success = true; - return result; - } - finally - { - ComUtilities.Release(ref target); - ComUtilities.Release(ref range); - } - }); - } -} - - diff --git a/src/ExcelMcp.Core/Commands/Screenshot/IScreenshotCommands.cs b/src/ExcelMcp.Core/Commands/Screenshot/IScreenshotCommands.cs deleted file mode 100644 index 2d5b77cb..00000000 --- a/src/ExcelMcp.Core/Commands/Screenshot/IScreenshotCommands.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System.Text.Json.Serialization; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Attributes; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.Screenshot; - -/// <summary> -/// Image quality level for screenshot capture. -/// Controls the output format and scale to balance visual fidelity against response size. -/// </summary> -public enum ScreenshotQuality -{ - /// <summary>JPEG at 75% scale. Recommended for most uses. ~4-8x smaller than High.</summary> - Medium = 0, - /// <summary>PNG at full scale. Maximum fidelity for detailed inspection.</summary> - High = 1, - /// <summary>JPEG at 50% scale. Smallest size, good for overview/layout verification.</summary> - Low = 2 -} - -/// <summary> -/// Result containing a screenshot image as base64-encoded image data. -/// </summary> -public class ScreenshotResult : OperationResult -{ - /// <summary>Base64-encoded image data</summary> - public string ImageBase64 { get; set; } = string.Empty; - - /// <summary>MIME type of the image (image/png or image/jpeg)</summary> - public string MimeType { get; set; } = "image/jpeg"; - - /// <summary>Image width in pixels</summary> - public int Width { get; set; } - - /// <summary>Image height in pixels</summary> - public int Height { get; set; } - - /// <summary>Sheet name that was captured</summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? SheetName { get; set; } - - /// <summary>Range address that was captured (e.g., "A1:F20")</summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? RangeAddress { get; set; } -} - -/// <summary> -/// Capture Excel worksheet content as images for visual verification. -/// Uses Excel's built-in rendering (CopyPicture) to capture ranges as PNG images. -/// Captures formatting, conditional formatting, charts, and all visual elements. -/// -/// ACTIONS: -/// - capture: Capture a specific range as an image -/// - capture-sheet: Capture the entire used area of a worksheet -/// -/// RETURNS: Base64-encoded image data with dimensions metadata. -/// For MCP: returned as native ImageContent (no file handling needed). -/// For CLI: use --output <path> to save the image directly to a PNG/JPEG file instead of returning base64 inline. -/// Quality defaults to Medium (JPEG 75% scale) which is 4-8x smaller than High (PNG). -/// Use High only when fine detail inspection is needed. -/// </summary> -[ServiceCategory("screenshot", "Screenshot")] -public interface IScreenshotCommands -{ - /// <summary> - /// Captures a specific range as an image. - /// For CLI: use --output <path> to save the image directly to a PNG/JPEG file. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Worksheet name (null for active sheet)</param> - /// <param name="rangeAddress">Range to capture (e.g., "A1:F20")</param> - /// <param name="quality">Image quality: Medium (default, JPEG 75% scale), High (PNG full scale), Low (JPEG 50% scale)</param> - /// <returns>Screenshot result with base64 image data</returns> - [ServiceAction("capture")] - ScreenshotResult CaptureRange(IExcelBatch batch, string? sheetName = null, string rangeAddress = "A1:Z30", ScreenshotQuality quality = ScreenshotQuality.Medium); - - /// <summary> - /// Captures the entire used area of a worksheet as an image. - /// For CLI: use --output <path> to save the image directly to a PNG/JPEG file. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Worksheet name (null for active sheet)</param> - /// <param name="quality">Image quality: Medium (default, JPEG 75% scale), High (PNG full scale), Low (JPEG 50% scale)</param> - /// <returns>Screenshot result with base64 image data</returns> - [ServiceAction("capture-sheet")] - ScreenshotResult CaptureSheet(IExcelBatch batch, string? sheetName = null, ScreenshotQuality quality = ScreenshotQuality.Medium); -} diff --git a/src/ExcelMcp.Core/Commands/Screenshot/ScreenshotCommands.cs b/src/ExcelMcp.Core/Commands/Screenshot/ScreenshotCommands.cs deleted file mode 100644 index 86122ce0..00000000 --- a/src/ExcelMcp.Core/Commands/Screenshot/ScreenshotCommands.cs +++ /dev/null @@ -1,330 +0,0 @@ -using System.Runtime.InteropServices; -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; - -namespace Sbroenne.ExcelMcp.Core.Commands.Screenshot; - -/// <summary> -/// Implementation of screenshot commands using Excel COM CopyPicture + ChartObject.Export. -/// </summary> -public class ScreenshotCommands : IScreenshotCommands -{ - // Excel COM constants - private const int XlScreen = 1; // xlScreen - required for CopyPicture to render correctly - private const int XlBitmap = 2; // xlBitmap - - // CopyPicture retry configuration - // After Save or large operations, Excel rendering can take several seconds. - // Retries with exponential backoff: 500ms, 1000ms, 1500ms, 2000ms, 2500ms, 3000ms, 3500ms - private const int CopyPictureMaxRetries = 10; - private const int CopyPictureRetryDelayMs = 700; - - // Export format constants - private const string FormatPng = "PNG"; - private const string FormatJpeg = "JPEG"; - - /// <summary> - /// Captures a specific range as an image. - /// </summary> - public ScreenshotResult CaptureRange(IExcelBatch batch, string? sheetName = null, string rangeAddress = "A1:Z30", ScreenshotQuality quality = ScreenshotQuality.Medium) - { - return batch.Execute((ctx, ct) => - { - dynamic? sheet = null; - dynamic? range = null; - try - { - sheet = string.IsNullOrWhiteSpace(sheetName) - ? ctx.Book.ActiveSheet - : ctx.Book.Worksheets[sheetName]; - - range = sheet.Range[rangeAddress]; - string actualSheet = sheet.Name?.ToString() ?? "Sheet1"; - string actualRange = range.Address?.ToString() ?? rangeAddress; - - return ExportRangeAsImage(ctx.App, sheet, range, actualSheet, actualRange, quality); - } - finally - { - ComUtilities.Release(ref range); - ComUtilities.Release(ref sheet); - } - }); - } - - /// <summary> - /// Captures the entire used area of a worksheet as an image. - /// If UsedRange exceeds 500 rows or 50 columns, it is capped to avoid - /// CopyPicture failures on sheets with formatting extending far beyond data. - /// </summary> - public ScreenshotResult CaptureSheet(IExcelBatch batch, string? sheetName = null, ScreenshotQuality quality = ScreenshotQuality.Medium) - { - return batch.Execute((ctx, ct) => - { - dynamic? sheet = null; - dynamic? usedRange = null; - dynamic? captureRange = null; - try - { - sheet = string.IsNullOrWhiteSpace(sheetName) - ? ctx.Book.ActiveSheet - : ctx.Book.Worksheets[sheetName]; - - usedRange = sheet.UsedRange; - string actualSheet = sheet.Name?.ToString() ?? "Sheet1"; - - int rows = (int)usedRange.Rows.Count; - int cols = (int)usedRange.Columns.Count; - - const int maxRows = 500; - const int maxCols = 50; - - if (rows > maxRows || cols > maxCols) - { - // Cap the range to avoid CopyPicture failures on enormous ranges - int startRow = (int)usedRange.Row; - int startCol = (int)usedRange.Column; - int endRow = startRow + Math.Min(rows, maxRows) - 1; - int endCol = startCol + Math.Min(cols, maxCols) - 1; - captureRange = sheet.Range[sheet.Cells[startRow, startCol], sheet.Cells[endRow, endCol]]; - } - - dynamic rangeToCapture = captureRange ?? usedRange; - string actualRange = rangeToCapture.Address?.ToString() ?? "A1"; - - return ExportRangeAsImage(ctx.App, sheet, rangeToCapture, actualSheet, actualRange, quality); - } - finally - { - ComUtilities.Release(ref captureRange); - ComUtilities.Release(ref usedRange); - ComUtilities.Release(ref sheet); - } - }); - } - - /// <summary> - /// Exports a range as a PNG image using CopyPicture + ChartObject.Export. - /// CopyPicture requires Excel to be visible for rendering. If Excel is hidden, - /// we temporarily show it, capture, then restore the previous visibility state. - /// - /// CRITICAL: After Save/large operations, Excel needs rendering time even if already visible. - /// This method includes delays to ensure Excel is fully rendered before capture. - /// </summary> - private static ScreenshotResult ExportRangeAsImage(dynamic app, dynamic sheet, dynamic range, string sheetName, string rangeAddress, ScreenshotQuality quality) - { - dynamic? chartObjects = null; - dynamic? chartObject = null; - dynamic? chart = null; - string? tempFile = null; - bool wasVisible = false; - - try - { - // CopyPicture requires Excel to be visible for UI rendering - wasVisible = (bool)app.Visible; - if (!wasVisible) - { - app.Visible = true; - - // Excel needs time to initialize its rendering pipeline after - // becoming visible. Without this, CopyPicture fails with - // "Unable to get the CopyPicture property" or crashes the process - // with RPC_S_SERVER_UNAVAILABLE (0x800706BA) under rapid cycling. - Thread.Sleep(2000); - } - else - { - // Excel is already visible, but may still be rendering from previous operations. - // Slightly longer delay to allow rendering pipeline to fully settle. - Thread.Sleep(1000); - } - - // Try to activate the Excel window to ensure it has focus for proper rendering. - // This helps ensure content is fully rendered before capture. - try - { - app.Activate(); - // Allow activation and message pump to settle - Thread.Sleep(500); - } - catch - { - // Activate may fail in some contexts (minimized, headless, etc.) - ignore - } - - // Get range dimensions for the chart - double width = Convert.ToDouble(range.Width); - double height = Convert.ToDouble(range.Height); - - // Cap dimensions to avoid huge images (max ~4096px equivalent at 96 DPI) - // Excel Width/Height are in points (1 point = 1.333 pixels at 96 DPI) - const double maxPoints = 3072; // ~4096px - if (width > maxPoints || height > maxPoints) - { - double scale = Math.Min(maxPoints / width, maxPoints / height); - width *= scale; - height *= scale; - } - - // Apply quality-based scale reduction before export. - // Lower quality = smaller chart dimensions = fewer pixels = smaller image. - // JPEG format (Medium/Low) is also ~4-8x smaller than PNG for the same content. - double qualityScale = quality switch - { - ScreenshotQuality.Low => 0.5, - ScreenshotQuality.Medium => 0.75, - _ => 1.0 // High: full scale - }; - width *= qualityScale; - height *= qualityScale; - - // Minimum useful size - width = Math.Max(width, 50); - height = Math.Max(height, 30); - - // Export format: JPEG for Medium/Low (much smaller), PNG for High (lossless) - string exportFormat = quality == ScreenshotQuality.High ? FormatPng : FormatJpeg; - string mimeType = quality == ScreenshotQuality.High ? "image/png" : "image/jpeg"; - string fileExt = quality == ScreenshotQuality.High ? "png" : "jpg"; - - // Copy range as picture (with retry — CopyPicture is clipboard-dependent - // and intermittently fails when Excel is still rendering after chart/table operations) - CopyPictureWithRetry(range); - - // Create a temporary ChartObject to paste into and export - chartObjects = sheet.ChartObjects(); - chartObject = chartObjects.Add(0, 0, width, height); - chart = chartObject.Chart; - - // Paste the copied picture into the chart - chart.Paste(); - - // Clear clipboard immediately after paste — releases clipboard for subsequent screenshot calls - // (otherwise marching ants remain and next CopyPicture may fail with clipboard contention) - try { app.CutCopyMode = false; } catch { /* best effort */ } - - // Export to temp file in the appropriate format - tempFile = Path.Combine(Path.GetTempPath(), $"excelmcp-screenshot-{Guid.NewGuid():N}.{fileExt}"); - chart.Export(tempFile, exportFormat); - - // Read and convert to base64 - byte[] imageBytes = File.ReadAllBytes(tempFile); - string base64 = Convert.ToBase64String(imageBytes); - - // Read pixel dimensions from file header - (int pixelWidth, int pixelHeight) = quality == ScreenshotQuality.High - ? GetPngDimensions(imageBytes) - : GetJpegDimensions(imageBytes); - - return new ScreenshotResult - { - Success = true, - ImageBase64 = base64, - MimeType = mimeType, - Width = pixelWidth, - Height = pixelHeight, - SheetName = sheetName, - RangeAddress = rangeAddress, - Message = $"Captured {rangeAddress} on '{sheetName}' ({pixelWidth}x{pixelHeight}px)" - }; - } - finally - { - // Clean up temp ChartObject from the worksheet - if (chartObject != null) - { - try { chartObject.Delete(); } catch { /* best effort */ } - } - - ComUtilities.Release(ref chart); - ComUtilities.Release(ref chartObject); - ComUtilities.Release(ref chartObjects); - - // Restore Excel visibility if we changed it - if (!wasVisible) - { - try { app.Visible = false; } catch { /* best effort */ } - } - - // Clean up temp file - if (tempFile != null && File.Exists(tempFile)) - { - try { File.Delete(tempFile); } catch { /* best effort */ } - } - } - } - - /// <summary> - /// Calls CopyPicture with retry logic. CopyPicture uses the clipboard and - /// intermittently fails with COMException when Excel is busy rendering - /// (e.g., after chart/table operations). Retries with increasing delay. - /// </summary> - private static void CopyPictureWithRetry(dynamic range) - { - for (int attempt = 0; attempt < CopyPictureMaxRetries; attempt++) - { - try - { - range.CopyPicture(XlScreen, XlBitmap); - return; - } - catch (COMException) when (attempt < CopyPictureMaxRetries - 1) - { - Thread.Sleep(CopyPictureRetryDelayMs * (attempt + 1)); - } - } - } - - /// <summary> - /// Reads width and height from PNG file header (IHDR chunk). - /// PNG format: 8-byte signature, then IHDR chunk with width (4 bytes) and height (4 bytes). - /// </summary> - private static (int width, int height) GetPngDimensions(byte[] data) - { - if (data.Length < 24) - return (0, 0); - - // PNG IHDR starts at byte 16 (after 8-byte signature + 4-byte length + 4-byte "IHDR") - int width = (data[16] << 24) | (data[17] << 16) | (data[18] << 8) | data[19]; - int height = (data[20] << 24) | (data[21] << 16) | (data[22] << 8) | data[23]; - - return (width, height); - } - - /// <summary> - /// Reads width and height from JPEG file by scanning for SOF (Start Of Frame) markers. - /// SOF markers (0xFF 0xC0..0xC3, 0xC5..0xC7, 0xC9..0xCB, 0xCD..0xCF) contain dimensions. - /// </summary> - private static (int width, int height) GetJpegDimensions(byte[] data) - { - if (data.Length < 4 || data[0] != 0xFF || data[1] != 0xD8) - return (0, 0); - - int i = 2; - while (i < data.Length - 8) - { - if (data[i] != 0xFF) break; - byte marker = data[i + 1]; - - // SOF markers that contain frame dimensions - bool isSof = marker is >= 0xC0 and <= 0xC3 - or >= 0xC5 and <= 0xC7 - or >= 0xC9 and <= 0xCB - or >= 0xCD and <= 0xCF; - - int segmentLength = (data[i + 2] << 8) | data[i + 3]; - - if (isSof && i + 8 < data.Length) - { - int height = (data[i + 5] << 8) | data[i + 6]; - int width = (data[i + 7] << 8) | data[i + 8]; - return (width, height); - } - - i += 2 + segmentLength; - } - - return (0, 0); - } -} diff --git a/src/ExcelMcp.Core/Commands/Sheet/ISheetCommands.cs b/src/ExcelMcp.Core/Commands/Sheet/ISheetCommands.cs deleted file mode 100644 index 8e189075..00000000 --- a/src/ExcelMcp.Core/Commands/Sheet/ISheetCommands.cs +++ /dev/null @@ -1,131 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Attributes; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Worksheet lifecycle management: create, rename, copy, delete, move, list sheets. -/// Use range for data operations. Use sheetstyle for tab colors and visibility. -/// -/// ATOMIC OPERATIONS: 'copy-to-file' and 'move-to-file' don't require a session - -/// they open/close files automatically. -/// -/// POSITIONING: For 'move', 'copy-to-file', 'move-to-file' - use 'before' OR 'after' -/// (not both) to position the sheet relative to another. If neither specified, moves to end. -/// -/// NOTE: MCP tool is manually implemented in ExcelWorksheetTool.cs to properly handle -/// mixed session requirements (copy-to-file and move-to-file are atomic and don't need sessions). -/// </summary> -[ServiceCategory("sheet", "Sheet")] -public interface ISheetCommands -{ - // === LIFECYCLE OPERATIONS === - - /// <summary> - /// Lists all worksheets in the workbook. - /// For multi-workbook batches, specify filePath to list sheets from a specific workbook. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="filePath">Optional file path when batch contains multiple workbooks. If omitted, uses primary workbook.</param> - [ServiceAction("list")] - WorksheetListResult List(IExcelBatch batch, string? filePath = null); - - /// <summary> - /// Creates a new worksheet. - /// For multi-workbook batches, specify filePath to create in a specific workbook. - /// Throws exception on error. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name for the new worksheet</param> - /// <param name="filePath">Optional file path when batch contains multiple workbooks. If omitted, creates in primary workbook.</param> - [ServiceAction("create")] - OperationResult Create(IExcelBatch batch, [RequiredParameter] string sheetName, string? filePath = null); - - /// <summary> - /// Renames a worksheet. - /// Throws exception on error. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="oldName">Current name of the worksheet</param> - /// <param name="newName">New name for the worksheet</param> - [ServiceAction("rename")] - OperationResult Rename(IExcelBatch batch, [RequiredParameter] string oldName, [RequiredParameter] string newName); - - /// <summary> - /// Copies a worksheet. - /// Throws exception on error. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sourceName">Name of the source worksheet</param> - /// <param name="targetName">Name for the copied worksheet</param> - [ServiceAction("copy")] - OperationResult Copy(IExcelBatch batch, [RequiredParameter] string sourceName, [RequiredParameter] string targetName); - - /// <summary> - /// Deletes a worksheet. - /// Throws exception on error. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet to delete</param> - [ServiceAction("delete")] - OperationResult Delete(IExcelBatch batch, [RequiredParameter] string sheetName); - - /// <summary> - /// Moves a worksheet to a new position within the workbook. - /// Use either beforeSheet OR afterSheet to specify position (not both). - /// If neither is specified, sheet moves to the end. - /// Throws exception on error. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the sheet to move</param> - /// <param name="beforeSheet">Optional: Name of sheet to position before</param> - /// <param name="afterSheet">Optional: Name of sheet to position after</param> - [ServiceAction("move")] - OperationResult Move(IExcelBatch batch, [RequiredParameter] string sheetName, string? beforeSheet = null, string? afterSheet = null); - - // === ATOMIC CROSS-FILE OPERATIONS === - // These operations don't require a session - they create temporary Excel instances internally. - - /// <summary> - /// Copies a worksheet to another file (atomic operation - no session required). - /// Creates a temporary Excel instance, opens both files, performs the copy, - /// saves the target file, and closes both files. - /// </summary> - /// <param name="sourceFile">Full path to the source workbook</param> - /// <param name="sourceSheet">Name of the sheet to copy</param> - /// <param name="targetFile">Full path to the target workbook</param> - /// <param name="targetSheetName">Optional: New name for the copied sheet (default: keeps original name)</param> - /// <param name="beforeSheet">Optional: Position before this sheet in target</param> - /// <param name="afterSheet">Optional: Position after this sheet in target</param> - [ServiceAction("copy-to-file")] - OperationResult CopyToFile( - [RequiredParameter] string sourceFile, - [RequiredParameter] string sourceSheet, - [RequiredParameter] string targetFile, - string? targetSheetName = null, - string? beforeSheet = null, - string? afterSheet = null); - - /// <summary> - /// Moves a worksheet to another file (atomic operation - no session required). - /// Creates a temporary Excel instance, opens both files, performs the move, - /// saves both files, and closes them. - /// This is the RECOMMENDED way to move sheets between files. - /// </summary> - /// <param name="sourceFile">Full path to the source workbook</param> - /// <param name="sourceSheet">Name of the sheet to move</param> - /// <param name="targetFile">Full path to the target workbook</param> - /// <param name="beforeSheet">Optional: Position before this sheet in target</param> - /// <param name="afterSheet">Optional: Position after this sheet in target</param> - [ServiceAction("move-to-file")] - OperationResult MoveToFile( - [RequiredParameter] string sourceFile, - [RequiredParameter] string sourceSheet, - [RequiredParameter] string targetFile, - string? beforeSheet = null, - string? afterSheet = null); -} - - - diff --git a/src/ExcelMcp.Core/Commands/Sheet/ISheetStyleCommands.cs b/src/ExcelMcp.Core/Commands/Sheet/ISheetStyleCommands.cs deleted file mode 100644 index d78494f4..00000000 --- a/src/ExcelMcp.Core/Commands/Sheet/ISheetStyleCommands.cs +++ /dev/null @@ -1,117 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Attributes; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Worksheet styling operations for tab colors and visibility. -/// Use sheet for lifecycle operations (create, rename, copy, delete, move). -/// -/// TAB COLORS: Use RGB values (0-255 each) to set custom tab colors for visual organization. -/// -/// VISIBILITY LEVELS: -/// - 'visible': Normal visible sheet -/// - 'hidden': Hidden but accessible via Format > Sheet > Unhide -/// - 'veryhidden': Only accessible via VBA (protection against casual unhiding) -/// </summary> -[ServiceCategory("sheet", "SheetStyle")] -[McpTool("worksheet_style", Title = "Worksheet Style Operations", Destructive = true, Category = "structure", - Description = "Worksheet styling: tab colors and visibility. TAB COLORS: RGB values 0-255 each for custom tab colors. VISIBILITY: visible (normal), hidden (accessible via Format > Sheet > Unhide), veryhidden (only accessible via VBA). Use worksheet for lifecycle operations.")] -public interface ISheetStyleCommands -{ - // === TAB COLOR OPERATIONS === - - /// <summary> - /// Sets the tab color for a worksheet using RGB values (0-255 each). - /// Excel uses BGR format internally, conversion is handled automatically. - /// Throws exception on error. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet to color</param> - /// <param name="red">Red color component (0-255)</param> - /// <param name="green">Green color component (0-255)</param> - /// <param name="blue">Blue color component (0-255)</param> - [ServiceAction("set-tab-color")] - OperationResult SetTabColor( - IExcelBatch batch, - [RequiredParameter] string sheetName, - [RequiredParameter] int red, - [RequiredParameter] int green, - [RequiredParameter] int blue); - - /// <summary> - /// Gets the tab color for a worksheet. - /// Returns RGB values and hex color, or HasColor=false if no color is set. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet</param> - [ServiceAction("get-tab-color")] - TabColorResult GetTabColor(IExcelBatch batch, [RequiredParameter] string sheetName); - - /// <summary> - /// Clears the tab color for a worksheet (resets to default). - /// Throws exception on error. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet</param> - [ServiceAction("clear-tab-color")] - OperationResult ClearTabColor(IExcelBatch batch, [RequiredParameter] string sheetName); - - // === VISIBILITY OPERATIONS === - - /// <summary> - /// Sets worksheet visibility level. - /// - visible: Normal visible state - /// - hidden: Hidden via UI, user can unhide - /// - veryhidden: Requires code to unhide (security/protection) - /// Throws exception on error. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet</param> - /// <param name="visibility">Visibility level: 'visible', 'hidden', or 'veryhidden'</param> - [ServiceAction("set-visibility")] - OperationResult SetVisibility( - IExcelBatch batch, - [RequiredParameter] string sheetName, - [RequiredParameter] - [FromString] SheetVisibility visibility); - - /// <summary> - /// Gets worksheet visibility level - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet</param> - [ServiceAction("get-visibility")] - SheetVisibilityResult GetVisibility(IExcelBatch batch, [RequiredParameter] string sheetName); - - /// <summary> - /// Shows a hidden or very hidden worksheet. - /// Convenience method equivalent to SetVisibility(..., SheetVisibility.Visible). - /// Throws exception on error. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet</param> - [ServiceAction("show")] - OperationResult Show(IExcelBatch batch, [RequiredParameter] string sheetName); - - /// <summary> - /// Hides a worksheet (user can unhide via Excel UI). - /// Convenience method equivalent to SetVisibility(..., SheetVisibility.Hidden). - /// Throws exception on error. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet</param> - [ServiceAction("hide")] - OperationResult Hide(IExcelBatch batch, [RequiredParameter] string sheetName); - - /// <summary> - /// Very hides a worksheet (requires code to unhide, for protection). - /// Convenience method equivalent to SetVisibility(..., SheetVisibility.VeryHidden). - /// Throws exception on error. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Name of the worksheet</param> - [ServiceAction("very-hide")] - OperationResult VeryHide(IExcelBatch batch, [RequiredParameter] string sheetName); -} diff --git a/src/ExcelMcp.Core/Commands/Sheet/SheetCommands.Lifecycle.cs b/src/ExcelMcp.Core/Commands/Sheet/SheetCommands.Lifecycle.cs deleted file mode 100644 index a2c7b117..00000000 --- a/src/ExcelMcp.Core/Commands/Sheet/SheetCommands.Lifecycle.cs +++ /dev/null @@ -1,441 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; -using Excel = Microsoft.Office.Interop.Excel; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Worksheet lifecycle operations (List, Create, Rename, Copy, Delete) -/// </summary> -public partial class SheetCommands -{ - /// <inheritdoc /> - public WorksheetListResult List(IExcelBatch batch, string? filePath = null) - { - var result = new WorksheetListResult { FilePath = filePath ?? batch.WorkbookPath }; - - return batch.Execute((ctx, ct) => - { - // Get the workbook to list from - dynamic workbook = filePath != null ? batch.GetWorkbook(filePath) : ctx.Book; - - dynamic? sheets = null; - try - { - sheets = workbook.Worksheets; - for (int i = 1; i <= sheets.Count; i++) - { - dynamic? sheet = null; - try - { - sheet = sheets.Item(i); - result.Worksheets.Add(new WorksheetInfo - { - Name = sheet.Name, - Index = i, - Visible = (int)sheet.Visible == -1 // xlSheetVisible = -1 - }); - } - finally - { - ComUtilities.Release(ref sheet); - } - } - result.Success = true; - return result; - } - finally - { - ComUtilities.Release(ref sheets); - } - }); - } - - /// <inheritdoc /> - public OperationResult Create(IExcelBatch batch, string sheetName, string? filePath = null) - { - return batch.Execute((ctx, ct) => - { - // Get the workbook to create sheet in - dynamic workbook = filePath != null ? batch.GetWorkbook(filePath) : ctx.Book; - - dynamic? sheets = null; - dynamic? newSheet = null; - try - { - sheets = workbook.Worksheets; - newSheet = sheets.Add(); - newSheet.Name = sheetName; - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref newSheet); - ComUtilities.Release(ref sheets); - } - }); - } - - /// <inheritdoc /> - public OperationResult Rename(IExcelBatch batch, string oldName, string newName) - { - return batch.Execute((ctx, ct) => - { - Excel.Worksheet? sheet = null; - try - { - sheet = ComUtilities.FindSheet(ctx.Book, oldName); - if (sheet == null) - { - throw new InvalidOperationException($"Sheet '{oldName}' not found."); - } - sheet.Name = newName; - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref sheet); - } - }); - } - - /// <inheritdoc /> - public OperationResult Copy(IExcelBatch batch, string sourceName, string targetName) - { - return batch.Execute((ctx, ct) => - { - Excel.Worksheet? sourceSheet = null; - dynamic? sheets = null; - dynamic? lastSheet = null; - dynamic? copiedSheet = null; - try - { - sourceSheet = ComUtilities.FindSheet(ctx.Book, sourceName); - if (sourceSheet == null) - { - throw new InvalidOperationException($"Sheet '{sourceName}' not found."); - } - sheets = ctx.Book.Worksheets; - lastSheet = sheets.Item(sheets.Count); - sourceSheet.Copy(After: lastSheet); - copiedSheet = sheets.Item(sheets.Count); - copiedSheet.Name = targetName; - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref copiedSheet); - ComUtilities.Release(ref lastSheet); - ComUtilities.Release(ref sheets); - ComUtilities.Release(ref sourceSheet); - } - }); - } - - /// <inheritdoc /> - public OperationResult Delete(IExcelBatch batch, string sheetName) - { - return batch.Execute((ctx, ct) => - { - Excel.Worksheet? sheet = null; - try - { - sheet = ComUtilities.FindSheet(ctx.Book, sheetName); - if (sheet == null) - { - throw new InvalidOperationException($"Sheet '{sheetName}' not found."); - } - sheet.Delete(); - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref sheet); - } - }); - } - - /// <inheritdoc /> - public OperationResult Move(IExcelBatch batch, string sheetName, string? beforeSheet = null, string? afterSheet = null) - { - // Validate parameters - if (!string.IsNullOrWhiteSpace(beforeSheet) && !string.IsNullOrWhiteSpace(afterSheet)) - { - throw new ArgumentException("Cannot specify both beforeSheet and afterSheet"); - } - - return batch.Execute((ctx, ct) => - { - Excel.Worksheet? sheet = null; - Excel.Worksheet? targetSheet = null; - dynamic? sheets = null; - dynamic? lastSheet = null; - try - { - sheet = ComUtilities.FindSheet(ctx.Book, sheetName); - if (sheet == null) - { - throw new InvalidOperationException($"Sheet '{sheetName}' not found."); - } - - // If no position specified, move to end - if (string.IsNullOrWhiteSpace(beforeSheet) && string.IsNullOrWhiteSpace(afterSheet)) - { - sheets = ctx.Book.Worksheets; - lastSheet = sheets.Item(sheets.Count); - sheet.Move(After: lastSheet); - } - else - { - // Find target sheet for positioning - string targetName = beforeSheet ?? afterSheet!; - targetSheet = ComUtilities.FindSheet(ctx.Book, targetName); - if (targetSheet == null) - { - throw new InvalidOperationException($"Target sheet '{targetName}' not found."); - } - - // Move using Excel COM API - if (!string.IsNullOrWhiteSpace(beforeSheet)) - { - sheet.Move(Before: targetSheet); - } - else - { - sheet.Move(After: targetSheet); - } - } - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref lastSheet); - ComUtilities.Release(ref sheets); - ComUtilities.Release(ref targetSheet); - ComUtilities.Release(ref sheet); - } - }); - } - - // === ATOMIC CROSS-FILE OPERATIONS === - - /// <inheritdoc /> - public OperationResult CopyToFile(string sourceFile, string sourceSheet, string targetFile, string? targetSheetName = null, string? beforeSheet = null, string? afterSheet = null) - { - // Validate positioning parameters - if (!string.IsNullOrWhiteSpace(beforeSheet) && !string.IsNullOrWhiteSpace(afterSheet)) - { - throw new ArgumentException("Cannot specify both beforeSheet and afterSheet. Choose one or neither."); - } - - // Validate file paths - if (string.IsNullOrWhiteSpace(sourceFile)) - throw new ArgumentException("sourceFile is required", nameof(sourceFile)); - if (string.IsNullOrWhiteSpace(targetFile)) - throw new ArgumentException("targetFile is required", nameof(targetFile)); - if (!File.Exists(sourceFile)) - throw new FileNotFoundException($"Source file not found: {sourceFile}"); - if (!File.Exists(targetFile)) - throw new FileNotFoundException($"Target file not found: {targetFile}"); - - // Normalize paths for comparison - string normalizedSource = Path.GetFullPath(sourceFile); - string normalizedTarget = Path.GetFullPath(targetFile); - if (string.Equals(normalizedSource, normalizedTarget, StringComparison.OrdinalIgnoreCase)) - { - throw new ArgumentException("Source and target files must be different. For same-file copy, use the 'copy' action."); - } - - // Create a batch with both files open in the same Excel instance - using var batch = ExcelSession.BeginBatch(sourceFile, targetFile); - - return batch.Execute((ctx, ct) => - { - dynamic? sourceWb = null; - dynamic? targetWb = null; - Excel.Worksheet? sourceSheetObj = null; - dynamic? targetSheets = null; - Excel.Worksheet? targetPositionSheet = null; - dynamic? copiedSheet = null; - - try - { - // Get both workbooks from the batch - sourceWb = batch.GetWorkbook(normalizedSource); - targetWb = batch.GetWorkbook(normalizedTarget); - - // Find source sheet - sourceSheetObj = ComUtilities.FindSheet(sourceWb, sourceSheet); - if (sourceSheetObj == null) - { - throw new InvalidOperationException($"Source sheet '{sourceSheet}' not found in '{Path.GetFileName(sourceFile)}'"); - } - - // Handle positioning - targetSheets = targetWb.Worksheets; - int? copiedSheetPosition = null; - - if (!string.IsNullOrWhiteSpace(beforeSheet)) - { - targetPositionSheet = ComUtilities.FindSheet(targetWb, beforeSheet); - if (targetPositionSheet == null) - { - throw new InvalidOperationException($"Target sheet '{beforeSheet}' not found in '{Path.GetFileName(targetFile)}'"); - } - // Get position before copy - the copied sheet will be at this position - copiedSheetPosition = Convert.ToInt32(targetPositionSheet.Index); - sourceSheetObj.Copy(Before: targetPositionSheet); - } - else if (!string.IsNullOrWhiteSpace(afterSheet)) - { - targetPositionSheet = ComUtilities.FindSheet(targetWb, afterSheet); - if (targetPositionSheet == null) - { - throw new InvalidOperationException($"Target sheet '{afterSheet}' not found in '{Path.GetFileName(targetFile)}'"); - } - // Get position before copy - the copied sheet will be at position + 1 - copiedSheetPosition = Convert.ToInt32(targetPositionSheet.Index) + 1; - sourceSheetObj.Copy(After: targetPositionSheet); - } - else - { - // Copy to end of target workbook - dynamic? lastSheet = targetSheets.Item(targetSheets.Count); - try - { - sourceSheetObj.Copy(After: lastSheet); - // Copied sheet will be at the end (new count) - copiedSheetPosition = targetSheets.Count; - } - finally - { - ComUtilities.Release(ref lastSheet!); - } - } - - // Rename if requested - use correct position based on where sheet was copied - if (!string.IsNullOrWhiteSpace(targetSheetName) && copiedSheetPosition.HasValue) - { - copiedSheet = targetSheets.Item(copiedSheetPosition.Value); - copiedSheet.Name = targetSheetName; - } - - // Save the target workbook (source unchanged, only target modified) - targetWb.Save(); - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref copiedSheet); - ComUtilities.Release(ref targetPositionSheet); - ComUtilities.Release(ref targetSheets); - ComUtilities.Release(ref sourceSheetObj); - } - }); - } - - /// <inheritdoc /> - public OperationResult MoveToFile(string sourceFile, string sourceSheet, string targetFile, string? beforeSheet = null, string? afterSheet = null) - { - // Validate positioning parameters - if (!string.IsNullOrWhiteSpace(beforeSheet) && !string.IsNullOrWhiteSpace(afterSheet)) - { - throw new ArgumentException("Cannot specify both beforeSheet and afterSheet. Choose one or neither."); - } - - // Validate file paths - if (string.IsNullOrWhiteSpace(sourceFile)) - throw new ArgumentException("sourceFile is required", nameof(sourceFile)); - if (string.IsNullOrWhiteSpace(targetFile)) - throw new ArgumentException("targetFile is required", nameof(targetFile)); - if (!File.Exists(sourceFile)) - throw new FileNotFoundException($"Source file not found: {sourceFile}"); - if (!File.Exists(targetFile)) - throw new FileNotFoundException($"Target file not found: {targetFile}"); - - // Normalize paths for comparison - string normalizedSource = Path.GetFullPath(sourceFile); - string normalizedTarget = Path.GetFullPath(targetFile); - if (string.Equals(normalizedSource, normalizedTarget, StringComparison.OrdinalIgnoreCase)) - { - throw new ArgumentException("Source and target files must be different. For same-file move, use the 'move' action."); - } - - // Create a batch with both files open in the same Excel instance - using var batch = ExcelSession.BeginBatch(sourceFile, targetFile); - - return batch.Execute((ctx, ct) => - { - dynamic? sourceWb = null; - dynamic? targetWb = null; - Excel.Worksheet? sourceSheetObj = null; - dynamic? targetSheets = null; - Excel.Worksheet? targetPositionSheet = null; - - try - { - // Get both workbooks from the batch - sourceWb = batch.GetWorkbook(normalizedSource); - targetWb = batch.GetWorkbook(normalizedTarget); - - // Find source sheet - sourceSheetObj = ComUtilities.FindSheet(sourceWb, sourceSheet); - if (sourceSheetObj == null) - { - throw new InvalidOperationException($"Source sheet '{sourceSheet}' not found in '{Path.GetFileName(sourceFile)}'"); - } - - // Handle positioning - targetSheets = targetWb.Worksheets; - - if (!string.IsNullOrWhiteSpace(beforeSheet)) - { - targetPositionSheet = ComUtilities.FindSheet(targetWb, beforeSheet); - if (targetPositionSheet == null) - { - throw new InvalidOperationException($"Target sheet '{beforeSheet}' not found in '{Path.GetFileName(targetFile)}'"); - } - sourceSheetObj.Move(Before: targetPositionSheet); - } - else if (!string.IsNullOrWhiteSpace(afterSheet)) - { - targetPositionSheet = ComUtilities.FindSheet(targetWb, afterSheet); - if (targetPositionSheet == null) - { - throw new InvalidOperationException($"Target sheet '{afterSheet}' not found in '{Path.GetFileName(targetFile)}'"); - } - sourceSheetObj.Move(After: targetPositionSheet); - } - else - { - // Move to end of target workbook - dynamic? lastSheet = targetSheets.Item(targetSheets.Count); - try - { - sourceSheetObj.Move(After: lastSheet); - } - finally - { - ComUtilities.Release(ref lastSheet!); - } - } - - // Save both workbooks (source lost a sheet, target gained one) - sourceWb.Save(); - targetWb.Save(); - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref targetPositionSheet); - ComUtilities.Release(ref targetSheets); - // Note: sourceSheetObj has been moved, don't release it - } - }); - } -} - - diff --git a/src/ExcelMcp.Core/Commands/Sheet/SheetCommands.TabColor.cs b/src/ExcelMcp.Core/Commands/Sheet/SheetCommands.TabColor.cs deleted file mode 100644 index ed088895..00000000 --- a/src/ExcelMcp.Core/Commands/Sheet/SheetCommands.TabColor.cs +++ /dev/null @@ -1,146 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; -using Excel = Microsoft.Office.Interop.Excel; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Worksheet tab color operations (SetTabColor, GetTabColor, ClearTabColor) -/// </summary> -public partial class SheetCommands -{ - /// <inheritdoc /> - public OperationResult SetTabColor(IExcelBatch batch, string sheetName, int red, int green, int blue) - { - // Validate RGB values - if (red < 0 || red > 255 || green < 0 || green > 255 || blue < 0 || blue > 255) - { - throw new ArgumentException("RGB values must be between 0 and 255"); - } - - return batch.Execute((ctx, ct) => - { - Excel.Worksheet? sheet = null; - dynamic? tab = null; - try - { - sheet = ComUtilities.FindSheet(ctx.Book, sheetName); - if (sheet == null) - { - throw new InvalidOperationException($"Sheet '{sheetName}' not found."); - } - - // Convert RGB to BGR format (Excel's color format) - // BGR = (Blue << 16) | (Green << 8) | Red - int bgrColor = (blue << 16) | (green << 8) | red; - - tab = sheet.Tab; - tab.Color = bgrColor; - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref tab); - ComUtilities.Release(ref sheet); - } - }); - } - - /// <inheritdoc /> - /// <inheritdoc /> - public TabColorResult GetTabColor(IExcelBatch batch, string sheetName) - { - var result = new TabColorResult { FilePath = batch.WorkbookPath }; - - return batch.Execute((ctx, ct) => - { - Excel.Worksheet? sheet = null; - dynamic? tab = null; - try - { - sheet = ComUtilities.FindSheet(ctx.Book, sheetName); - if (sheet == null) - { - throw new InvalidOperationException($"Sheet '{sheetName}' not found."); - } - - tab = sheet.Tab; - dynamic colorValue = tab.Color; - - // Excel's ColorIndex.xlColorIndexAutomatic = -4105 - // When no custom color is set, Excel might return various values - // Check ColorIndex property instead for more reliable detection - dynamic colorIndex = tab.ColorIndex; - - // xlColorIndexNone = -4142, xlColorIndexAutomatic = -4105 - // If ColorIndex is negative or color value indicates no custom color - if (colorIndex is int idx && (idx == -4142 || idx == -4105 || idx < 0)) - { - result.Success = true; - result.HasColor = false; - return result; - } - - // Also check if color value itself indicates no custom color - if (colorValue is null or (dynamic?)0) - { - result.Success = true; - result.HasColor = false; - return result; - } - - // Convert BGR to RGB - int bgrColor = Convert.ToInt32(colorValue); - int red = bgrColor & 0xFF; - int green = (bgrColor >> 8) & 0xFF; - int blue = (bgrColor >> 16) & 0xFF; - - result.Success = true; - result.HasColor = true; - result.Red = red; - result.Green = green; - result.Blue = blue; - result.HexColor = $"#{red:X2}{green:X2}{blue:X2}"; - - return result; - } - finally - { - ComUtilities.Release(ref tab); - ComUtilities.Release(ref sheet); - } - }); - } - - /// <inheritdoc /> - public OperationResult ClearTabColor(IExcelBatch batch, string sheetName) - { - return batch.Execute((ctx, ct) => - { - Excel.Worksheet? sheet = null; - dynamic? tab = null; - try - { - sheet = ComUtilities.FindSheet(ctx.Book, sheetName); - if (sheet == null) - { - throw new InvalidOperationException($"Sheet '{sheetName}' not found."); - } - - tab = sheet.Tab; - // Set ColorIndex to xlColorIndexNone (-4142) to clear color - tab.ColorIndex = -4142; // xlColorIndexNone - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref tab); - ComUtilities.Release(ref sheet); - } - }); - } -} - - - diff --git a/src/ExcelMcp.Core/Commands/Sheet/SheetCommands.Visibility.cs b/src/ExcelMcp.Core/Commands/Sheet/SheetCommands.Visibility.cs deleted file mode 100644 index 03293744..00000000 --- a/src/ExcelMcp.Core/Commands/Sheet/SheetCommands.Visibility.cs +++ /dev/null @@ -1,90 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; -using Excel = Microsoft.Office.Interop.Excel; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Worksheet visibility operations (SetVisibility, GetVisibility, Show, Hide, VeryHide) -/// </summary> -public partial class SheetCommands -{ - /// <inheritdoc /> - public OperationResult SetVisibility(IExcelBatch batch, string sheetName, SheetVisibility visibility) - { - return batch.Execute((ctx, ct) => - { - Excel.Worksheet? sheet = null; - try - { - sheet = ComUtilities.FindSheet(ctx.Book, sheetName); - if (sheet == null) - { - throw new InvalidOperationException($"Sheet '{sheetName}' not found."); - } - - // Set visibility using the enum value (maps to XlSheetVisibility) - sheet.Visible = (Excel.XlSheetVisibility)(int)visibility; - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref sheet); - } - }); - } - - /// <inheritdoc /> - /// <inheritdoc /> - public SheetVisibilityResult GetVisibility(IExcelBatch batch, string sheetName) - { - var result = new SheetVisibilityResult { FilePath = batch.WorkbookPath }; - - return batch.Execute((ctx, ct) => - { - Excel.Worksheet? sheet = null; - try - { - sheet = ComUtilities.FindSheet(ctx.Book, sheetName); - if (sheet == null) - { - throw new InvalidOperationException($"Sheet '{sheetName}' not found."); - } - - int visibilityValue = (int)sheet.Visible; - result.Visibility = (SheetVisibility)visibilityValue; - result.VisibilityName = result.Visibility.ToString(); - result.Success = true; - - return result; - } - finally - { - ComUtilities.Release(ref sheet); - } - }); - } - - /// <inheritdoc /> - public OperationResult Show(IExcelBatch batch, string sheetName) - { - return SetVisibility(batch, sheetName, SheetVisibility.Visible); - } - - /// <inheritdoc /> - public OperationResult Hide(IExcelBatch batch, string sheetName) - { - return SetVisibility(batch, sheetName, SheetVisibility.Hidden); - } - - /// <inheritdoc /> - public OperationResult VeryHide(IExcelBatch batch, string sheetName) - { - return SetVisibility(batch, sheetName, SheetVisibility.VeryHidden); - } -} - - - - diff --git a/src/ExcelMcp.Core/Commands/Sheet/SheetCommands.cs b/src/ExcelMcp.Core/Commands/Sheet/SheetCommands.cs deleted file mode 100644 index a18eeacf..00000000 --- a/src/ExcelMcp.Core/Commands/Sheet/SheetCommands.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// Worksheet lifecycle and appearance management commands. -/// Implements both ISheetCommands (lifecycle) and ISheetStyleCommands (appearance). -/// </summary> -public partial class SheetCommands : ISheetCommands, ISheetStyleCommands -{ -} - - diff --git a/src/ExcelMcp.Core/Commands/Slicer/ISlicerCommands.cs b/src/ExcelMcp.Core/Commands/Slicer/ISlicerCommands.cs deleted file mode 100644 index d9ae1328..00000000 --- a/src/ExcelMcp.Core/Commands/Slicer/ISlicerCommands.cs +++ /dev/null @@ -1,111 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Attributes; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.Slicer; - -/// <summary> -/// Slicer visual filters for PivotTables and Excel Tables. -/// -/// PIVOTTABLE SLICERS: create-slicer, list-slicers, set-slicer-selection, delete-slicer. -/// TABLE SLICERS: create-table-slicer, list-table-slicers, set-table-slicer-selection, delete-table-slicer. -/// -/// NAMING: Auto-generate descriptive names like {FieldName}Slicer (e.g., RegionSlicer). -/// -/// SELECTION: selectedItems as list of strings. -/// Empty list clears filter (shows all items). Set clearFirst=false to add to existing selection. -/// </summary> -[ServiceCategory("slicer", "Slicer")] -[McpTool("slicer", Title = "Slicer Operations", Destructive = true, Category = "analysis", - Description = "Slicer management: create, list, configure, delete visual filtering controls for PivotTables and Tables. NAMING: Auto-generate descriptive names like RegionSlicer, CategorySlicer. PIVOTTABLE SLICERS: create-slicer, list-slicers, set-slicer-selection, delete-slicer. TABLE SLICERS: create-table-slicer, list-table-slicers, set-table-slicer-selection, delete-table-slicer. SELECTION: selectedItems as JSON array of strings. Use clearFirst=false to add to existing selection.")] -public interface ISlicerCommands -{ - /// <summary> - /// Creates a slicer for a PivotTable field. - /// Slicers provide visual filtering for PivotTable data. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Name of the PivotTable to create slicer for</param> - /// <param name="fieldName">Name of the field to use for the slicer</param> - /// <param name="slicerName">Name for the new slicer</param> - /// <param name="destinationSheet">Worksheet where slicer will be placed</param> - /// <param name="position">Top-left cell position for the slicer (e.g., "H2")</param> - /// <returns>Created slicer details with available items</returns> - [ServiceAction("create-slicer")] - SlicerResult CreateSlicer(IExcelBatch batch, string pivotTableName, - string fieldName, string slicerName, string destinationSheet, string position); - - /// <summary> - /// Lists all slicers in the workbook, optionally filtered by PivotTable. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="pivotTableName">Optional PivotTable name to filter slicers (null = all slicers)</param> - /// <returns>List of slicers with names, fields, positions, and selections</returns> - [ServiceAction("list-slicers")] - SlicerListResult ListSlicers(IExcelBatch batch, string? pivotTableName = null); - - /// <summary> - /// Sets the selection for a slicer, filtering the connected PivotTable(s). - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="slicerName">Name of the slicer to modify</param> - /// <param name="selectedItems">Items to select (show in PivotTable)</param> - /// <param name="clearFirst">If true, clears existing selection before setting new items (default: true)</param> - /// <returns>Updated slicer state with current selection</returns> - [ServiceAction("set-slicer-selection")] - SlicerResult SetSlicerSelection(IExcelBatch batch, string slicerName, - List<string> selectedItems, bool clearFirst = true); - - /// <summary> - /// Deletes a slicer from the workbook. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="slicerName">Name of the slicer to delete</param> - /// <returns>Operation result indicating success or failure</returns> - [ServiceAction("delete-slicer")] - OperationResult DeleteSlicer(IExcelBatch batch, string slicerName); - - /// <summary> - /// Creates a slicer for an Excel Table column. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="tableName">Name of the Excel Table</param> - /// <param name="columnName">Name of the column to use for the slicer</param> - /// <param name="slicerName">Name for the new slicer</param> - /// <param name="destinationSheet">Worksheet where slicer will be placed</param> - /// <param name="position">Top-left cell position for the slicer (e.g., "H2")</param> - /// <returns>Created slicer details with available items</returns> - [ServiceAction("create-table-slicer")] - SlicerResult CreateTableSlicer(IExcelBatch batch, string tableName, - string columnName, string slicerName, string destinationSheet, string position); - - /// <summary> - /// Lists all table slicers in the workbook, optionally filtered by table. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="tableName">Optional table name to filter slicers (null = all table slicers)</param> - /// <returns>List of slicers with names, columns, positions, and selections</returns> - [ServiceAction("list-table-slicers")] - SlicerListResult ListTableSlicers(IExcelBatch batch, string? tableName = null); - - /// <summary> - /// Sets the selection for a table slicer, filtering the connected table. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="slicerName">Name of the slicer to modify</param> - /// <param name="selectedItems">Items to select (show in table)</param> - /// <param name="clearFirst">If true, clears existing selection before setting new items (default: true)</param> - /// <returns>Updated slicer state with current selection</returns> - [ServiceAction("set-table-slicer-selection")] - SlicerResult SetTableSlicerSelection(IExcelBatch batch, string slicerName, - List<string> selectedItems, bool clearFirst = true); - - /// <summary> - /// Deletes a table slicer from the workbook. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="slicerName">Name of the slicer to delete</param> - /// <returns>Operation result indicating success or failure</returns> - [ServiceAction("delete-table-slicer")] - OperationResult DeleteTableSlicer(IExcelBatch batch, string slicerName); -} diff --git a/src/ExcelMcp.Core/Commands/Slicer/SlicerCommands.cs b/src/ExcelMcp.Core/Commands/Slicer/SlicerCommands.cs deleted file mode 100644 index 1443df55..00000000 --- a/src/ExcelMcp.Core/Commands/Slicer/SlicerCommands.cs +++ /dev/null @@ -1,51 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands.PivotTable; -using Sbroenne.ExcelMcp.Core.Commands.Table; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.Slicer; - -/// <summary> -/// Slicer commands bridging PivotTable and Table slicer operations. -/// </summary> -public sealed class SlicerCommands : ISlicerCommands -{ - private readonly PivotTableCommands _pivotTableCommands = new(); - private readonly TableCommands _tableCommands = new(); - - /// <inheritdoc /> - public SlicerResult CreateSlicer(IExcelBatch batch, string pivotTableName, - string fieldName, string slicerName, string destinationSheet, string position) - => _pivotTableCommands.CreateSlicer(batch, pivotTableName, fieldName, slicerName, destinationSheet, position); - - /// <inheritdoc /> - public SlicerListResult ListSlicers(IExcelBatch batch, string? pivotTableName = null) - => _pivotTableCommands.ListSlicers(batch, pivotTableName); - - /// <inheritdoc /> - public SlicerResult SetSlicerSelection(IExcelBatch batch, string slicerName, - List<string> selectedItems, bool clearFirst = true) - => _pivotTableCommands.SetSlicerSelection(batch, slicerName, selectedItems, clearFirst); - - /// <inheritdoc /> - public OperationResult DeleteSlicer(IExcelBatch batch, string slicerName) - => _pivotTableCommands.DeleteSlicer(batch, slicerName); - - /// <inheritdoc /> - public SlicerResult CreateTableSlicer(IExcelBatch batch, string tableName, - string columnName, string slicerName, string destinationSheet, string position) - => _tableCommands.CreateTableSlicer(batch, tableName, columnName, slicerName, destinationSheet, position); - - /// <inheritdoc /> - public SlicerListResult ListTableSlicers(IExcelBatch batch, string? tableName = null) - => _tableCommands.ListTableSlicers(batch, tableName); - - /// <inheritdoc /> - public SlicerResult SetTableSlicerSelection(IExcelBatch batch, string slicerName, - List<string> selectedItems, bool clearFirst = true) - => _tableCommands.SetTableSlicerSelection(batch, slicerName, selectedItems, clearFirst); - - /// <inheritdoc /> - public OperationResult DeleteTableSlicer(IExcelBatch batch, string slicerName) - => _tableCommands.DeleteTableSlicer(batch, slicerName); -} diff --git a/src/ExcelMcp.Core/Commands/Table/ITableColumnCommands.cs b/src/ExcelMcp.Core/Commands/Table/ITableColumnCommands.cs deleted file mode 100644 index 60c1b0cf..00000000 --- a/src/ExcelMcp.Core/Commands/Table/ITableColumnCommands.cs +++ /dev/null @@ -1,154 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Attributes; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.Table; - -/// <summary> -/// Table column, filtering, and sorting operations for Excel Tables (ListObjects). -/// Use table for table-level lifecycle and data operations. -/// -/// FILTERING: -/// - 'apply-filter': Simple criteria filter (e.g., ">100", "=Active", "<>Closed") -/// - 'apply-filter-values': Filter by exact values (provide list of values to include) -/// - 'clear-filters': Remove all active filters -/// - 'get-filters': See current filter state -/// -/// SORTING: -/// - 'sort': Single column sort (ascending/descending) -/// - 'sort-multi': Multi-column sort (provide list of {columnName, ascending} objects) -/// -/// COLUMN MANAGEMENT: -/// - 'add-column'/'remove-column'/'rename-column': Modify table structure -/// -/// NUMBER FORMATS: Use US locale format codes (e.g., '#,##0.00', '0%', 'yyyy-mm-dd') -/// </summary> -[ServiceCategory("tablecolumn", "TableColumn")] -[McpTool("table_column", Title = "Table Column Operations", Destructive = true, Category = "data", - Description = "Table column, filtering, and sorting operations. FILTERING: apply-filter (criteria like >100, =Active), apply-filter-values (JSON array of exact values), clear-filters, get-filters. SORTING: sort (single column), sort-multi (JSON array of {columnName, ascending}). COLUMNS: add-column, remove-column, rename-column. NUMBER FORMATS: US locale codes (#,##0.00, 0%, yyyy-mm-dd). Use table for lifecycle and data operations.")] -public interface ITableColumnCommands -{ - // === FILTER OPERATIONS === - - /// <summary> - /// Applies a filter to a table column with single criteria - /// </summary> - /// <param name="tableName">Name of the Excel table</param> - /// <param name="columnName">Name of the column to filter</param> - /// <param name="criteria">Filter criteria string (e.g., '>100', '=Active', '<>Closed')</param> - /// <exception cref="InvalidOperationException">Table or column not found</exception> - [ServiceAction("apply-filter")] - OperationResult ApplyFilter(IExcelBatch batch, string tableName, string columnName, string criteria); - - /// <summary> - /// Applies a filter to a table column with multiple values - /// </summary> - /// <param name="tableName">Name of the Excel table</param> - /// <param name="columnName">Name of the column to filter</param> - /// <param name="values">List of exact values to include in the filter</param> - /// <exception cref="InvalidOperationException">Table or column not found</exception> - [ServiceAction("apply-filter-values")] - OperationResult ApplyFilterValues(IExcelBatch batch, string tableName, string columnName, List<string> values); - - /// <summary> - /// Clears all filters from a table - /// </summary> - /// <param name="tableName">Name of the Excel table</param> - /// <exception cref="InvalidOperationException">Table not found</exception> - [ServiceAction("clear-filters")] - OperationResult ClearFilters(IExcelBatch batch, string tableName); - - /// <summary> - /// Gets current filter state for all columns in a table - /// </summary> - /// <param name="tableName">Name of the Excel table</param> - [ServiceAction("get-filters")] - TableFilterResult GetFilters(IExcelBatch batch, string tableName); - - // === COLUMN OPERATIONS === - - /// <summary> - /// Adds a new column to a table - /// </summary> - /// <param name="tableName">Name of the Excel table</param> - /// <param name="columnName">Name for the new column</param> - /// <param name="position">1-based column position (optional, defaults to end of table)</param> - /// <exception cref="InvalidOperationException">Table not found or position invalid</exception> - [ServiceAction("add-column")] - OperationResult AddColumn(IExcelBatch batch, string tableName, string columnName, int? position = null); - - /// <summary> - /// Removes a column from a table - /// </summary> - /// <param name="tableName">Name of the Excel table</param> - /// <param name="columnName">Name of the column to remove</param> - /// <exception cref="InvalidOperationException">Table or column not found</exception> - [ServiceAction("remove-column")] - OperationResult RemoveColumn(IExcelBatch batch, string tableName, string columnName); - - /// <summary> - /// Renames a column in a table - /// </summary> - /// <param name="tableName">Name of the Excel table</param> - /// <param name="oldName">Current column name</param> - /// <param name="newName">New column name</param> - /// <exception cref="InvalidOperationException">Table or column not found</exception> - [ServiceAction("rename-column")] - OperationResult RenameColumn(IExcelBatch batch, string tableName, string oldName, string newName); - - // === STRUCTURED REFERENCE OPERATIONS === - - /// <summary> - /// Gets structured reference information for a table region or column - /// </summary> - /// <param name="tableName">Name of the Excel table</param> - /// <param name="region">Table region: 'Data', 'Headers', 'Totals', or 'All'</param> - /// <param name="columnName">Optional column name for column-specific reference</param> - [ServiceAction("get-structured-reference")] - TableStructuredReferenceResult GetStructuredReference(IExcelBatch batch, string tableName, [FromString] TableRegion region, string? columnName = null); - - // === SORT OPERATIONS === - - /// <summary> - /// Sorts a table by a single column - /// </summary> - /// <param name="tableName">Name of the Excel table</param> - /// <param name="columnName">Column to sort by</param> - /// <param name="ascending">Sort order: true = ascending (A-Z, 0-9), false = descending (default: true)</param> - /// <exception cref="InvalidOperationException">Table or column not found</exception> - [ServiceAction("sort")] - OperationResult Sort(IExcelBatch batch, string tableName, string columnName, bool ascending = true); - - /// <summary> - /// Sorts a table by multiple columns - /// </summary> - /// <param name="tableName">Name of the Excel table</param> - /// <param name="sortColumns">List of sort specifications: [{columnName: 'Col1', ascending: true}, ...] - applied in order</param> - /// <exception cref="InvalidOperationException">Table or column not found</exception> - [ServiceAction("sort-multi")] - OperationResult SortMulti(IExcelBatch batch, string tableName, List<TableSortColumn> sortColumns); - - // === NUMBER FORMATTING === - - /// <summary> - /// Gets number formats for a table column - /// Delegates to RangeCommands.GetNumberFormatsAsync() on column range - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="tableName">Table name</param> - /// <param name="columnName">Column name</param> - [ServiceAction("get-column-number-format")] - RangeNumberFormatResult GetColumnNumberFormat(IExcelBatch batch, string tableName, string columnName); - - /// <summary> - /// Sets uniform number format for entire table column - /// Delegates to RangeCommands.SetNumberFormatAsync() on column data range (excludes header) - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="tableName">Name of the Excel table</param> - /// <param name="columnName">Name of the column to format</param> - /// <param name="formatCode">Number format code in US locale (e.g., '#,##0.00', '0%', 'yyyy-mm-dd')</param> - /// <exception cref="InvalidOperationException">Table or column not found, or format code invalid</exception> - [ServiceAction("set-column-number-format")] - OperationResult SetColumnNumberFormat(IExcelBatch batch, string tableName, string columnName, string formatCode); -} diff --git a/src/ExcelMcp.Core/Commands/Table/ITableCommands.cs b/src/ExcelMcp.Core/Commands/Table/ITableCommands.cs deleted file mode 100644 index e919f098..00000000 --- a/src/ExcelMcp.Core/Commands/Table/ITableCommands.cs +++ /dev/null @@ -1,186 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Attributes; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.Table; - -/// <summary> -/// Excel Tables (ListObjects) - lifecycle and data operations. -/// Tables provide structured references, automatic formatting, and Data Model integration. -/// -/// BEST PRACTICE: Use 'list' to check existing tables before creating. -/// Prefer 'append'/'resize'/'rename' over delete+recreate to preserve references. -/// -/// WARNING: Deleting tables used as PivotTable sources or in Data Model relationships will break those objects. -/// -/// DATA MODEL WORKFLOW: To analyze worksheet data with DAX/Power Pivot: -/// 1. Create or identify an Excel Table on a worksheet -/// 2. Use 'add-to-datamodel' to add the table to Power Pivot -/// 3. Then use datamodel to create DAX measures on it -/// -/// DAX-BACKED TABLES: Create tables populated by DAX EVALUATE queries: -/// - 'create-from-dax': Create a new table backed by a DAX query (e.g., SUMMARIZE, FILTER) -/// - 'update-dax': Update the DAX query for an existing DAX-backed table -/// - 'get-dax': Get the DAX query info for a table (check if it's DAX-backed) -/// -/// Related: tablecolumn (filter/sort/columns), datamodel (DAX measures, evaluate queries) -/// </summary> -[ServiceCategory("table", "Table")] -[McpTool("table", Title = "Table Operations", Destructive = true, Category = "data", - Description = "Excel Tables (ListObjects) - lifecycle and data operations. CONVERT TO TABLE: When user asks to 'format as table', 'create a table', 'put data in an Excel Table', or 'use a table' — use table(action: 'create') on the data range. Excel Tables provide built-in alternating row colors, automatic filter arrows, structured references, and automatic expansion. WORKFLOW: write data to range first, then table(action: 'create') to convert. STYLING: Pass tableStyle on create (TableStyleLight1-21, TableStyleMedium1-28, TableStyleDark1-11) or use set-style action later — these are the ONLY ways to style a table. Never apply range_format to table header or data rows — it conflicts with the table style system. BEST PRACTICE: List before creating, prefer append/resize/rename over delete+recreate. WARNING: Deleting tables used as PivotTable sources or in Data Model breaks those objects. DATA MODEL: add-to-datamodel to load into Power Pivot, then datamodel for DAX measures. APPEND: rows (inline JSON 2D array) or rowsFile (.json/.csv). Use table_column for filtering/sorting/columns.")] -public interface ITableCommands -{ - /// <summary> - /// Lists all Excel Tables in the workbook - /// </summary> - [ServiceAction("list")] - TableListResult List(IExcelBatch batch); - - /// <summary> - /// Creates a new Excel Table from a range - /// </summary> - /// <param name="sheetName">Name of the worksheet to create the table on</param> - /// <param name="tableName">Name for the new table (must be unique in workbook)</param> - /// <param name="rangeAddress">Cell range address for the table (e.g., 'A1:D10')</param> - /// <param name="hasHeaders">True if first row contains column headers (default: true)</param> - /// <param name="tableStyle">Table style name (e.g., 'TableStyleMedium2', 'TableStyleLight1'). Optional.</param> - /// <exception cref="InvalidOperationException">Sheet not found, table name already exists, or range invalid</exception> - [ServiceAction("create")] - OperationResult Create(IExcelBatch batch, string sheetName, string tableName, string rangeAddress, bool hasHeaders = true, string? tableStyle = null); - - /// <summary> - /// Renames an Excel Table - /// </summary> - /// <param name="tableName">Current name of the table</param> - /// <param name="newName">New name for the table (must be unique in workbook)</param> - /// <exception cref="InvalidOperationException">Table not found or new name already exists</exception> - [ServiceAction("rename")] - OperationResult Rename(IExcelBatch batch, string tableName, string newName); - - /// <summary> - /// Deletes an Excel Table (converts back to range) - /// </summary> - /// <param name="tableName">Name of the table to delete</param> - /// <exception cref="InvalidOperationException">Table not found</exception> - [ServiceAction("delete")] - OperationResult Delete(IExcelBatch batch, string tableName); - - /// <summary> - /// Gets detailed information about an Excel Table - /// </summary> - /// <param name="tableName">Name of the table</param> - [ServiceAction("read")] - TableInfoResult Read(IExcelBatch batch, string tableName); - - /// <summary> - /// Resizes an Excel Table to a new range - /// </summary> - /// <param name="tableName">Name of the table to resize</param> - /// <param name="newRange">New range address (e.g., 'A1:F20')</param> - /// <exception cref="InvalidOperationException">Table not found or new range invalid</exception> - [ServiceAction("resize")] - OperationResult Resize(IExcelBatch batch, string tableName, string newRange); - - /// <summary> - /// Toggles the totals row for an Excel Table - /// </summary> - /// <param name="tableName">Name of the table</param> - /// <param name="showTotals">True to show totals row, false to hide</param> - /// <exception cref="InvalidOperationException">Table not found</exception> - [ServiceAction("toggle-totals")] - OperationResult ToggleTotals(IExcelBatch batch, string tableName, bool showTotals); - - /// <summary> - /// Sets the totals function for a specific column in an Excel Table - /// </summary> - /// <param name="tableName">Name of the table</param> - /// <param name="columnName">Name of the column to set total function on</param> - /// <param name="totalFunction">Totals function name: Sum, Count, Average, Min, Max, CountNums, StdDev, Var, None</param> - /// <exception cref="InvalidOperationException">Table or column not found</exception> - [ServiceAction("set-column-total")] - OperationResult SetColumnTotal(IExcelBatch batch, string tableName, string columnName, string totalFunction); - - /// <summary> - /// Appends rows to an Excel Table (table auto-expands). - /// Provide EITHER rows (inline JSON 2D array) OR rowsFile (path to .json or .csv file), not both. - /// </summary> - /// <param name="tableName">Name of the table to append to (table auto-expands)</param> - /// <param name="rows">2D array of row data to append - column order must match table columns. Optional if rowsFile is provided.</param> - /// <param name="rowsFile">Path to a JSON or CSV file containing the rows to append. JSON: 2D array. CSV: rows/columns. Alternative to inline rows parameter.</param> - /// <exception cref="InvalidOperationException">Table not found or append failed</exception> - [ServiceAction("append")] - OperationResult Append(IExcelBatch batch, string tableName, List<List<object?>>? rows = null, string? rowsFile = null); - - /// <summary> - /// Retrieves data rows from a table, optionally limited to currently visible rows. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="tableName">Name of the table to read data from</param> - /// <param name="visibleOnly">True to return only visible (non-filtered) rows; false for all rows (default: false)</param> - /// <exception cref="InvalidOperationException">Table not found</exception> - [ServiceAction("get-data")] - TableDataResult GetData(IExcelBatch batch, string tableName, bool visibleOnly = false); - - /// <summary> - /// Changes the style of an Excel Table - /// </summary> - /// <param name="tableName">Name of the table to style</param> - /// <param name="tableStyle">Table style name (e.g., 'TableStyleMedium2', 'TableStyleLight1', 'TableStyleDark1')</param> - /// <exception cref="InvalidOperationException">Table not found or invalid style</exception> - [ServiceAction("set-style")] - OperationResult SetStyle(IExcelBatch batch, string tableName, string tableStyle); - - /// <summary> - /// Adds an Excel Table to the Power Pivot Data Model. - /// When column names contain literal bracket characters (e.g., [ACR_CM1]), those columns cannot - /// be referenced in DAX formulas. Use stripBracketColumnNames=true to automatically rename such - /// columns in the source table (removing brackets) before adding to the Data Model. - /// When stripBracketColumnNames=false (default), bracket column names are reported in BracketColumnsFound. - /// </summary> - /// <param name="tableName">Name of the table to add</param> - /// <param name="stripBracketColumnNames">When true, renames source table columns that contain literal bracket characters (removes brackets) before adding to the Data Model. This modifies the Excel table column headers in the worksheet.</param> - /// <exception cref="InvalidOperationException">Table not found or model not available</exception> - [ServiceAction("add-to-data-model")] - AddToDataModelResult AddToDataModel(IExcelBatch batch, string tableName, bool stripBracketColumnNames = false); - - // === DAX-BACKED TABLE OPERATIONS === - - /// <summary> - /// Creates a new Excel Table backed by a DAX EVALUATE query. - /// The table will be connected to the Data Model and refresh when the model refreshes. - /// Uses Model.CreateModelWorkbookConnection + xlCmdDAX + ListObjects.Add pattern. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="sheetName">Target worksheet name for the new table</param> - /// <param name="tableName">Name for the new DAX-backed table</param> - /// <param name="daxQuery">DAX EVALUATE query (e.g., 'EVALUATE Sales' or 'EVALUATE SUMMARIZE(...)')</param> - /// <param name="targetCell">Target cell address for table placement (default: 'A1')</param> - /// <exception cref="ArgumentException">Thrown when required parameters are missing</exception> - /// <exception cref="InvalidOperationException">Sheet not found, table name exists, or no Data Model</exception> - [ServiceAction("create-from-dax")] - OperationResult CreateFromDax(IExcelBatch batch, string sheetName, string tableName, string daxQuery, string? targetCell = null); - - /// <summary> - /// Updates the DAX query for an existing DAX-backed Excel Table. - /// The table must have been created with CreateFromDax or manually connected to a DAX query. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="tableName">Name of the DAX-backed table to update</param> - /// <param name="daxQuery">New DAX EVALUATE query (e.g., 'EVALUATE SUMMARIZE(...)')</param> - /// <exception cref="ArgumentException">Thrown when required parameters are missing</exception> - /// <exception cref="InvalidOperationException">Table not found or table is not DAX-backed</exception> - [ServiceAction("update-dax")] - OperationResult UpdateDax(IExcelBatch batch, string tableName, string daxQuery); - - /// <summary> - /// Gets the DAX query and connection information for a DAX-backed Excel Table. - /// Returns empty query info if table is not backed by a DAX query. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="tableName">Name of the table</param> - /// <returns>Result containing DAX query info (if any)</returns> - /// <exception cref="ArgumentException">Thrown when tableName is missing</exception> - /// <exception cref="InvalidOperationException">Table not found</exception> - [ServiceAction("get-dax")] - TableDaxInfoResult GetDax(IExcelBatch batch, string tableName); -} diff --git a/src/ExcelMcp.Core/Commands/Table/TableCommands.Columns.cs b/src/ExcelMcp.Core/Commands/Table/TableCommands.Columns.cs deleted file mode 100644 index 3878d5f9..00000000 --- a/src/ExcelMcp.Core/Commands/Table/TableCommands.Columns.cs +++ /dev/null @@ -1,203 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.Table; - -/// <summary> -/// Table column management operations (NEW) -/// </summary> -public partial class TableCommands -{ - /// <inheritdoc /> - public OperationResult AddColumn(IExcelBatch batch, string tableName, string columnName, int? position = null) - { - // Security: Validate table name - ValidateTableName(tableName); - - return batch.Execute((ctx, ct) => - { - dynamic? table = null; - dynamic? listColumns = null; - dynamic? newColumn = null; - try - { - table = FindTable(ctx.Book, tableName); - - // Check if column already exists - listColumns = table.ListColumns; - for (int i = 1; i <= listColumns.Count; i++) - { - dynamic? column = null; - try - { - column = listColumns.Item(i); - if (column.Name == columnName) - { - throw new InvalidOperationException($"Column '{columnName}' already exists in table '{tableName}'"); - } - } - finally - { - ComUtilities.Release(ref column); - } - } - - // Add column at specified position or at the end - if (position.HasValue) - { - newColumn = listColumns.Add(Position: position.Value); - } - else - { - newColumn = listColumns.Add(); // Adds at end - } - - newColumn.Name = columnName; - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref newColumn); - ComUtilities.Release(ref listColumns); - ComUtilities.Release(ref table); - } - }); - } - - /// <inheritdoc /> - public OperationResult RemoveColumn(IExcelBatch batch, string tableName, string columnName) - { - // Security: Validate table name - ValidateTableName(tableName); - - return batch.Execute((ctx, ct) => - { - dynamic? table = null; - dynamic? listColumns = null; - dynamic? column = null; - try - { - table = FindTable(ctx.Book, tableName); - - // Find column - listColumns = table.ListColumns; - for (int i = 1; i <= listColumns.Count; i++) - { - dynamic? col = null; - try - { - col = listColumns.Item(i); - if (col.Name == columnName) - { - column = col; - break; - } - } - finally - { - if (col != null && col.Name != columnName) - { - ComUtilities.Release(ref col); - } - } - } - - if (column == null) - { - throw new InvalidOperationException($"Column '{columnName}' not found in table '{tableName}'"); - } - - // Delete column - column.Delete(); - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref column); - ComUtilities.Release(ref listColumns); - ComUtilities.Release(ref table); - } - }); - } - - /// <inheritdoc /> - public OperationResult RenameColumn(IExcelBatch batch, string tableName, string oldName, string newName) - { - // Security: Validate table name - ValidateTableName(tableName); - - return batch.Execute((ctx, ct) => - { - dynamic? table = null; - dynamic? listColumns = null; - dynamic? column = null; - try - { - table = FindTable(ctx.Book, tableName); - - // Find column - listColumns = table.ListColumns; - for (int i = 1; i <= listColumns.Count; i++) - { - dynamic? col = null; - try - { - col = listColumns.Item(i); - if (col.Name == oldName) - { - column = col; - break; - } - } - finally - { - if (col != null && col.Name != oldName) - { - ComUtilities.Release(ref col); - } - } - } - - if (column == null) - { - throw new InvalidOperationException($"Column '{oldName}' not found in table '{tableName}'"); - } - - // Check if new name already exists - for (int i = 1; i <= listColumns.Count; i++) - { - dynamic? col = null; - try - { - col = listColumns.Item(i); - if (col.Name == newName) - { - throw new InvalidOperationException($"Column '{newName}' already exists in table '{tableName}'"); - } - } - finally - { - ComUtilities.Release(ref col); - } - } - - // Rename column - column.Name = newName; - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref column); - ComUtilities.Release(ref listColumns); - ComUtilities.Release(ref table); - } - }); - } -} - - - diff --git a/src/ExcelMcp.Core/Commands/Table/TableCommands.Data.cs b/src/ExcelMcp.Core/Commands/Table/TableCommands.Data.cs deleted file mode 100644 index 55f04571..00000000 --- a/src/ExcelMcp.Core/Commands/Table/TableCommands.Data.cs +++ /dev/null @@ -1,302 +0,0 @@ -using System.Globalization; -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands.Range; -using Sbroenne.ExcelMcp.Core.Models; -using Sbroenne.ExcelMcp.Core.Utilities; -using Excel = Microsoft.Office.Interop.Excel; - -namespace Sbroenne.ExcelMcp.Core.Commands.Table; - -/// <summary> -/// Table data operations (AppendRows) -/// </summary> -public partial class TableCommands -{ - /// <inheritdoc /> - public OperationResult Append(IExcelBatch batch, string tableName, List<List<object?>>? rows = null, string? rowsFile = null) - { - // Security: Validate table name - ValidateTableName(tableName); - - // Resolve rows from inline parameter or file - var resolvedRows = ParameterTransforms.ResolveValuesOrFile(rows, rowsFile, "rows"); - - return batch.Execute((ctx, ct) => - { - dynamic? table = null; - dynamic? sheet = null; - dynamic? dataBodyRange = null; - int originalCalculation = -1; // xlCalculationAutomatic = -4105, xlCalculationManual = -4135 - bool calculationChanged = false; - - try - { - table = FindTable(ctx.Book, tableName); - - sheet = table.Parent; - - // Validate data - if (resolvedRows.Count == 0) - { - throw new ArgumentException("No data to append", nameof(rows)); - } - - // Get current table size - int currentRow; - dataBodyRange = table.DataBodyRange; - if (dataBodyRange != null) - { - currentRow = dataBodyRange.Row + dataBodyRange.Rows.Count; - } - else - { - // Table has only headers - dynamic? headerRange = null; - try - { - headerRange = table.HeaderRowRange; - currentRow = headerRange.Row + 1; - } - finally - { - ComUtilities.Release(ref headerRange); - } - } - - int columnCount = table.ListColumns.Count; - int rowsToAdd = resolvedRows.Count; - - // CRITICAL: Temporarily disable automatic calculation to prevent Excel from - // hanging when appended data triggers dependent formulas that reference Data Model/DAX. - // Without this, setting values can block the COM interface during recalculation. - originalCalculation = (int)ctx.App.Calculation; - if (originalCalculation != -4135) // xlCalculationManual - { - ctx.App.Calculation = (Excel.XlCalculation)(-4135); // xlCalculationManual - calculationChanged = true; - } - - // Write data to cells below the table - for (int i = 0; i < resolvedRows.Count; i++) - { - var rowValues = resolvedRows[i]; - for (int j = 0; j < Math.Min(rowValues.Count, columnCount); j++) - { - dynamic? cell = null; - try - { - cell = sheet.Cells[currentRow + i, table.Range.Column + j]; - cell.Value2 = RangeHelpers.ConvertToCellValue(rowValues[j]); - } - finally - { - ComUtilities.Release(ref cell); - } - } - } - - // Restore calculation before resize so the table can recalculate after the operation - if (calculationChanged && originalCalculation != -1) - { - try - { - ctx.App.Calculation = (Excel.XlCalculation)originalCalculation; - calculationChanged = false; // Mark as restored - } - catch (System.Runtime.InteropServices.COMException) - { - // Ignore errors restoring calculation mode - will try again in finally - } - } - - // Resize table to include new rows - int newLastRow = currentRow + rowsToAdd - 1; - int newLastCol = table.Range.Column + columnCount - 1; - string newRangeAddress = $"{sheet.Cells[table.Range.Row, table.Range.Column].Address}:{sheet.Cells[newLastRow, newLastCol].Address}"; - - dynamic? resizeRange = null; - try - { - resizeRange = sheet.Range[newRangeAddress]; - table.Resize(resizeRange); - } - finally - { - ComUtilities.Release(ref resizeRange); - } - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - // Restore original calculation mode if not already restored - if (calculationChanged && originalCalculation != -1) - { - try - { - ctx.App.Calculation = (Excel.XlCalculation)originalCalculation; - } - catch (System.Runtime.InteropServices.COMException) - { - // Ignore errors restoring calculation mode - not critical - } - } - ComUtilities.Release(ref dataBodyRange); - ComUtilities.Release(ref sheet); - ComUtilities.Release(ref table); - } - }); - } - - /// <inheritdoc /> - public TableDataResult GetData(IExcelBatch batch, string tableName, bool visibleOnly) - { - // Security: Validate table name - ValidateTableName(tableName); - - var result = new TableDataResult - { - FilePath = batch.WorkbookPath, - TableName = tableName - }; - - return batch.Execute((ctx, ct) => - { - dynamic? table = null; - dynamic? listColumns = null; - dynamic? listRows = null; - dynamic? dataBodyRange = null; - try - { - table = FindTable(ctx.Book, tableName); - - listColumns = table.ListColumns; - int columnCount = listColumns.Count; - result.ColumnCount = columnCount; - - for (int i = 1; i <= columnCount; i++) - { - dynamic? column = null; - try - { - column = listColumns.Item(i); - string? columnName = column.Name; - if (!string.IsNullOrEmpty(columnName)) - { - result.Headers.Add(columnName); - } - } - finally - { - ComUtilities.Release(ref column); - } - } - - dataBodyRange = table.DataBodyRange; - if (dataBodyRange == null) - { - result.Success = true; - result.RowCount = 0; - return result; - } - - object? rawValues = dataBodyRange.Value2; - if (rawValues == null) - { - result.Success = true; - result.RowCount = 0; - return result; - } - - listRows = table.ListRows; - int listRowCount = listRows?.Count ?? 0; - - if (rawValues is object[,] array2D) - { - int rows = array2D.GetLength(0); - int cols = array2D.GetLength(1); - - for (int r = 1; r <= rows; r++) - { - bool includeRow = !visibleOnly; - if (!includeRow) - { - includeRow = IsListRowVisible(listRows, listRowCount, r); - } - - if (!includeRow) - { - continue; - } - - var row = new List<object?>(cols); - for (int c = 1; c <= cols; c++) - { - row.Add(array2D[r, c]); - } - result.Data.Add(row); - } - } - else - { - bool includeRow = !visibleOnly || IsListRowVisible(listRows, listRowCount, 1); - if (includeRow) - { - result.Data.Add([rawValues]); - } - } - - result.RowCount = result.Data.Count; - result.Success = true; - return result; - } - finally - { - ComUtilities.Release(ref listRows); - ComUtilities.Release(ref dataBodyRange); - ComUtilities.Release(ref listColumns); - ComUtilities.Release(ref table); - } - }); - } - - private static bool IsListRowVisible(dynamic? listRows, int listRowCount, int index) - { - if (listRows == null || index > listRowCount) - { - return true; - } - - dynamic? listRow = null; - dynamic? rowRange = null; - dynamic? entireRow = null; - try - { - listRow = listRows.Item(index); - rowRange = listRow.Range; - entireRow = rowRange.EntireRow; - - object? hiddenValue = entireRow.Hidden; - bool hidden = hiddenValue switch - { - bool b => b, - null => false, - string s when bool.TryParse(s, out var parsed) => parsed, - IConvertible convertible => convertible.ToBoolean(CultureInfo.InvariantCulture), - _ => false - }; - - return !hidden; - } - finally - { - ComUtilities.Release(ref entireRow); - ComUtilities.Release(ref rowRange); - ComUtilities.Release(ref listRow); - } - } -} - - - diff --git a/src/ExcelMcp.Core/Commands/Table/TableCommands.DataModel.cs b/src/ExcelMcp.Core/Commands/Table/TableCommands.DataModel.cs deleted file mode 100644 index dab8a90b..00000000 --- a/src/ExcelMcp.Core/Commands/Table/TableCommands.DataModel.cs +++ /dev/null @@ -1,217 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.Table; - -/// <summary> -/// Table Data Model integration operations (AddToDataModel) -/// </summary> -public partial class TableCommands -{ - /// <inheritdoc /> - public AddToDataModelResult AddToDataModel(IExcelBatch batch, string tableName, bool stripBracketColumnNames = false) - { - // Security: Validate table name - ValidateTableName(tableName); - - return batch.Execute((ctx, ct) => - { - dynamic? table = null; - dynamic? model = null; - dynamic? modelTables = null; - try - { - table = FindTable(ctx.Book, tableName); - - // Data Model is always available in Excel 2013+ (no need to check) - model = ctx.Book.Model; - modelTables = model.ModelTables; - - // Check if table is already in the Data Model via ModelTables - for (int i = 1; i <= modelTables.Count; i++) - { - dynamic? modelTable = null; - try - { - modelTable = modelTables.Item(i); - string sourceTableName = modelTable.SourceName; - if (sourceTableName == tableName || sourceTableName.EndsWith($"[{tableName}]", StringComparison.Ordinal)) - { - throw new InvalidOperationException($"Table '{tableName}' is already in the Data Model"); - } - } - finally - { - ComUtilities.Release(ref modelTable); - } - } - - // Detect and optionally strip bracket-escaped column names - // Columns with literal brackets (e.g., [ACR_CM1]) cannot be referenced in DAX - var bracketColumnNames = FindBracketColumnNames(table); - string[]? bracketColumnsFound = null; - string[]? bracketColumnsRenamed = null; - - if (bracketColumnNames.Count > 0) - { - if (stripBracketColumnNames) - { - // Rename columns in source table to remove brackets before adding to Data Model - StripBracketColumnNames(table, bracketColumnNames); - bracketColumnsRenamed = bracketColumnNames.ToArray(); - } - else - { - bracketColumnsFound = bracketColumnNames.ToArray(); - } - } - - // Create a connection for the table using the sigma_coding VBA pattern - // ConnectionString: "WORKSHEET;{DirectoryPath}" (directory only, not full file path!) - // CommandText: "{WorkbookName}!{TableName}" (not SQL query!) - // lCmdtype: xlCmdExcel = 7 (THE KEY - not 4 or 8!) - const int xlCmdExcel = 7; - string connectionName = $"WorkbookConnection_{ctx.Book.Name}!{tableName}"; - - // Add table to Data Model using sigma_coding VBA pattern - dynamic? workbookConnections = null; - dynamic? newConnection = null; - try - { - workbookConnections = ctx.Book.Connections; - - // Double-check: Connection name shouldn't exist - for (int i = 1; i <= workbookConnections.Count; i++) - { - dynamic? conn = null; - try - { - conn = workbookConnections.Item(i); - if (conn.Name == connectionName) - { - throw new InvalidOperationException($"Table '{tableName}' is already in the Data Model"); - } - } - finally - { - ComUtilities.Release(ref conn); - } - } - - // Create the connection using EXACT pattern from sigma_coding VBA - newConnection = workbookConnections.Add2( - connectionName, // Name - $"Excel Table: {tableName}", // Description - $"WORKSHEET;{ctx.Book.Path}", // ConnectionString: "WORKSHEET;{DirectoryPath}" - $"{ctx.Book.Name}!{tableName}", // CommandText: "{WorkbookName}!{TableName}" - xlCmdExcel, // lCmdtype: 7 (THE CRITICAL DIFFERENCE!) - true, // CreateModelConnection: true - false // ImportRelationships: false - ); - } - finally - { - ComUtilities.Release(ref newConnection); - ComUtilities.Release(ref workbookConnections); - } - - // Table is immediately available in Data Model - no refresh needed - // Connections.Add2() makes the table accessible for relationships/measures instantly - - var result = new AddToDataModelResult { Success = true, FilePath = batch.WorkbookPath }; - if (bracketColumnsFound?.Length > 0) - result.BracketColumnsFound = bracketColumnsFound; - if (bracketColumnsRenamed?.Length > 0) - result.BracketColumnsRenamed = bracketColumnsRenamed; - return result; - } - finally - { - // Release COM objects - ComUtilities.Release(ref modelTables); - ComUtilities.Release(ref model); - ComUtilities.Release(ref table); - } - }); - } - - /// <summary> - /// Finds column names in the Excel Table that contain literal bracket characters. - /// Such columns cannot be referenced in DAX formulas after being added to the Data Model. - /// </summary> - private static List<string> FindBracketColumnNames(dynamic table) - { - var bracketColumns = new List<string>(); - dynamic? listColumns = null; - try - { - listColumns = table.ListColumns; - int count = listColumns.Count; - for (int i = 1; i <= count; i++) - { - dynamic? col = null; - try - { - col = listColumns.Item(i); - string name = col.Name?.ToString() ?? string.Empty; - if (name.Contains('[') || name.Contains(']')) - { - bracketColumns.Add(name); - } - } - finally - { - ComUtilities.Release(ref col); - } - } - } - finally - { - ComUtilities.Release(ref listColumns); - } - return bracketColumns; - } - - /// <summary> - /// Strips literal bracket characters from the names of columns in the source Excel Table. - /// Modifies the worksheet table column headers in place. - /// </summary> - private static void StripBracketColumnNames(dynamic table, List<string> bracketColumnNames) - { - dynamic? listColumns = null; - try - { - listColumns = table.ListColumns; - int count = listColumns.Count; - for (int i = 1; i <= count; i++) - { - dynamic? col = null; - try - { - col = listColumns.Item(i); - string name = col.Name?.ToString() ?? string.Empty; - if (bracketColumnNames.Contains(name)) - { - string stripped = name.Replace("[", string.Empty).Replace("]", string.Empty); - if (!string.IsNullOrWhiteSpace(stripped)) - { - col.Name = stripped; - } - } - } - finally - { - ComUtilities.Release(ref col); - } - } - } - finally - { - ComUtilities.Release(ref listColumns); - } - } -} - - - diff --git a/src/ExcelMcp.Core/Commands/Table/TableCommands.Dax.cs b/src/ExcelMcp.Core/Commands/Table/TableCommands.Dax.cs deleted file mode 100644 index 5c8af699..00000000 --- a/src/ExcelMcp.Core/Commands/Table/TableCommands.Dax.cs +++ /dev/null @@ -1,357 +0,0 @@ -using System.Runtime.InteropServices; -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; -using Excel = Microsoft.Office.Interop.Excel; - -namespace Sbroenne.ExcelMcp.Core.Commands.Table; - -/// <summary> -/// Table DAX operations (create-from-dax, update-dax, get-dax) -/// </summary> -public partial class TableCommands -{ - // Excel constants for DAX operations - private const int xlSrcModel = 4; // PowerPivot Data Model source type - private const int xlCmdDAX = 8; // DAX command type - // xlYes is defined in TableCommands.Sort.cs - - /// <summary> - /// Finds the WorkbookConnection for a table by trying the TableObject path first, - /// then falling back to the QueryTable path. Returns null if neither works. - /// Caller is responsible for releasing the out parameters. - /// </summary> - private static dynamic? FindTableWorkbookConnection( - dynamic table, - out dynamic? tableObject, - out dynamic? queryTable) - { - queryTable = null; - - // Try the TableObject path first (for xlSrcModel tables created with ListObjects.Add) - try - { - tableObject = table.TableObject; - if (tableObject != null) - { - dynamic? conn = tableObject.WorkbookConnection; - if (conn != null) return conn; - } - } - catch (COMException) - { - tableObject = null; - } - - // Fall back to QueryTable path (for QueryTables.Add based tables) - try - { - queryTable = table.QueryTable; - if (queryTable != null) - { - dynamic? conn = queryTable.WorkbookConnection; - if (conn != null) return conn; - } - } - catch (COMException) - { - queryTable = null; - } - - return null; - } - - /// <inheritdoc /> - public OperationResult CreateFromDax(IExcelBatch batch, string sheetName, string tableName, string daxQuery, string? targetCell = null) - { - // Validate parameters - if (string.IsNullOrWhiteSpace(sheetName)) - { - throw new ArgumentException("sheetName is required for create-from-dax action", nameof(sheetName)); - } - - if (string.IsNullOrWhiteSpace(tableName)) - { - throw new ArgumentException("tableName is required for create-from-dax action", nameof(tableName)); - } - - ValidateTableName(tableName); - - if (string.IsNullOrWhiteSpace(daxQuery)) - { - throw new ArgumentException("daxQuery is required for create-from-dax action", nameof(daxQuery)); - } - - // Default target cell - targetCell ??= "A1"; - - return batch.Execute((ctx, ct) => - { - dynamic? model = null; - dynamic? modelWbConn = null; - dynamic? modelConnection = null; - Excel.Worksheet? sheet = null; - dynamic? destRange = null; - dynamic? listObjects = null; - dynamic? listObject = null; - - try - { - // Check if table name already exists - if (TableExists(ctx.Book, tableName)) - { - throw new InvalidOperationException($"Table '{tableName}' already exists"); - } - - // Get the sheet - sheet = ComUtilities.FindSheet(ctx.Book, sheetName); - if (sheet == null) - { - throw new InvalidOperationException($"Sheet '{sheetName}' not found"); - } - - // Check if workbook has Data Model and get first table name - // CreateModelWorkbookConnection requires a ModelTable name to create the connection - model = ctx.Book.Model; - dynamic? modelTables = null; - string? baseModelTableName = null; - try - { - modelTables = model.ModelTables; - if (modelTables == null || modelTables.Count == 0) - { - throw new InvalidOperationException("Workbook has no Data Model tables. Add data to the Data Model first using powerquery or table add-to-datamodel."); - } - - // Get the first ModelTable name to use as base for connection - dynamic? firstTable = modelTables.Item(1); - try - { - baseModelTableName = firstTable.Name?.ToString(); - } - finally - { - ComUtilities.Release(ref firstTable); - } - } - finally - { - ComUtilities.Release(ref modelTables); - } - - if (string.IsNullOrEmpty(baseModelTableName)) - { - throw new InvalidOperationException("Could not get table name from Data Model."); - } - - // Create a model workbook connection using an existing ModelTable name - // This creates a connection that we can then configure for DAX queries - modelWbConn = model.CreateModelWorkbookConnection(baseModelTableName); - modelConnection = modelWbConn.ModelConnection; - - // Configure the connection for DAX EVALUATE query - modelConnection.CommandType = xlCmdDAX; // 8 = xlCmdDAX - modelConnection.CommandText = daxQuery; - - // Refresh to execute the DAX query - modelWbConn.Refresh(); - - // Get target range for the table - destRange = sheet.Range[targetCell]; - - // Create Excel Table (ListObject) backed by the DAX query - listObjects = sheet.ListObjects; - listObject = listObjects.Add( - xlSrcModel, // Source type: PowerPivot Data Model - modelWbConn, // The ModelWorkbookConnection with DAX - Type.Missing, // LinkSource (not used) - xlYes, // HasHeaders: Yes - destRange // Target range - ); - - // Set the table name - listObject.Name = tableName; - - // Refresh the table to populate data - listObject.Refresh(); - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref listObject); - ComUtilities.Release(ref listObjects); - ComUtilities.Release(ref destRange); - ComUtilities.Release(ref sheet); - ComUtilities.Release(ref modelConnection); - ComUtilities.Release(ref modelWbConn); - ComUtilities.Release(ref model); - } - }); - } - - /// <inheritdoc /> - public OperationResult UpdateDax(IExcelBatch batch, string tableName, string daxQuery) - { - // Validate parameters - if (string.IsNullOrWhiteSpace(tableName)) - { - throw new ArgumentException("tableName is required for update-dax action", nameof(tableName)); - } - - ValidateTableName(tableName); - - if (string.IsNullOrWhiteSpace(daxQuery)) - { - throw new ArgumentException("daxQuery is required for update-dax action", nameof(daxQuery)); - } - - return batch.Execute((ctx, ct) => - { - dynamic? table = null; - dynamic? tableObject = null; - dynamic? queryTable = null; - dynamic? workbookConnection = null; - dynamic? modelConnection = null; - - try - { - // Find the table - table = FindTable(ctx.Book, tableName); - - workbookConnection = FindTableWorkbookConnection(table, out tableObject, out queryTable); - - if (workbookConnection == null) - { - throw new InvalidOperationException($"Table '{tableName}' is not connected to a data source. Only DAX-backed tables can be updated."); - } - - // Get the model connection - try - { - modelConnection = workbookConnection.ModelConnection; - } - catch (COMException) - { - throw new InvalidOperationException($"Table '{tableName}' does not have a ModelConnection. Use update-dax only with DAX-backed tables."); - } - - if (modelConnection == null) - { - throw new InvalidOperationException($"Table '{tableName}' is not backed by a Model connection. Use update-dax only with DAX-backed tables."); - } - - // Check if current command type is DAX - int currentCmdType = Convert.ToInt32(modelConnection.CommandType); - if (currentCmdType != xlCmdDAX) - { - throw new InvalidOperationException($"Table '{tableName}' has command type {currentCmdType}, not xlCmdDAX (8). Use update-dax only with DAX-backed tables."); - } - - // Update the DAX query - modelConnection.CommandText = daxQuery; - - // Refresh to execute the new query - workbookConnection.Refresh(); - table.Refresh(); - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref modelConnection); - ComUtilities.Release(ref workbookConnection); - ComUtilities.Release(ref queryTable); - ComUtilities.Release(ref tableObject); - ComUtilities.Release(ref table); - } - }); - } - - /// <inheritdoc /> - public TableDaxInfoResult GetDax(IExcelBatch batch, string tableName) - { - // Validate parameters - if (string.IsNullOrWhiteSpace(tableName)) - { - throw new ArgumentException("tableName is required for get-dax action", nameof(tableName)); - } - - ValidateTableName(tableName); - - var result = new TableDaxInfoResult - { - FilePath = batch.WorkbookPath, - TableName = tableName - }; - - return batch.Execute((ctx, ct) => - { - dynamic? table = null; - dynamic? tableObject = null; - dynamic? queryTable = null; - dynamic? workbookConnection = null; - dynamic? modelConnection = null; - - try - { - // Find the table - table = FindTable(ctx.Book, tableName); - - workbookConnection = FindTableWorkbookConnection(table, out tableObject, out queryTable); - - if (workbookConnection == null) - { - result.HasDaxConnection = false; - result.Success = true; - return result; - } - - // Try to get the model connection - try - { - modelConnection = workbookConnection.ModelConnection; - } - catch (COMException) - { - result.HasDaxConnection = false; - result.Success = true; - return result; - } - - if (modelConnection == null) - { - result.HasDaxConnection = false; - result.Success = true; - return result; - } - - // Check if command type is DAX - int cmdType = Convert.ToInt32(modelConnection.CommandType); - if (cmdType == xlCmdDAX) - { - result.HasDaxConnection = true; - result.DaxQuery = modelConnection.CommandText?.ToString(); - result.ModelConnectionName = workbookConnection.Name?.ToString(); - } - else - { - result.HasDaxConnection = false; - } - - result.Success = true; - return result; - } - finally - { - ComUtilities.Release(ref modelConnection); - ComUtilities.Release(ref workbookConnection); - ComUtilities.Release(ref queryTable); - ComUtilities.Release(ref tableObject); - ComUtilities.Release(ref table); - } - }); - } -} - - diff --git a/src/ExcelMcp.Core/Commands/Table/TableCommands.Filters.cs b/src/ExcelMcp.Core/Commands/Table/TableCommands.Filters.cs deleted file mode 100644 index d034614e..00000000 --- a/src/ExcelMcp.Core/Commands/Table/TableCommands.Filters.cs +++ /dev/null @@ -1,308 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.Table; - -/// <summary> -/// Table filter operations (NEW) -/// </summary> -public partial class TableCommands -{ - /// <inheritdoc /> - public OperationResult ApplyFilter(IExcelBatch batch, string tableName, string columnName, string criteria) - { - // Security: Validate table name - ValidateTableName(tableName); - - return batch.Execute((ctx, ct) => - { - dynamic? table = null; - dynamic? autoFilter = null; - try - { - table = FindTable(ctx.Book, tableName); - - // Find column index - int columnIndex = -1; - dynamic? listColumns = null; - try - { - listColumns = table.ListColumns; - for (int i = 1; i <= listColumns.Count; i++) - { - dynamic? column = null; - try - { - column = listColumns.Item(i); - if (column.Name == columnName) - { - columnIndex = i; - break; - } - } - finally - { - ComUtilities.Release(ref column); - } - } - } - finally - { - ComUtilities.Release(ref listColumns); - } - - if (columnIndex == -1) - { - throw new InvalidOperationException($"Column '{columnName}' not found in table '{tableName}'"); - } - - // Apply filter - autoFilter = table.AutoFilter; - if (autoFilter == null) - { - // AutoFilter not enabled - enable it first - dynamic? range = null; - try - { - range = table.Range; - range.AutoFilter(Field: 1); // Enable with default - autoFilter = table.AutoFilter; - } - finally - { - ComUtilities.Release(ref range); - } - } - - // Apply filter to specific field - // xlFilterValues = 7, xlAnd = 1 - int xlFilterValues = 7; - autoFilter.Range.AutoFilter( - Field: columnIndex, - Criteria1: criteria, - Operator: xlFilterValues - ); - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref autoFilter); - ComUtilities.Release(ref table); - } - }); - } - - /// <inheritdoc /> - public OperationResult ApplyFilterValues(IExcelBatch batch, string tableName, string columnName, List<string> values) - { - // Security: Validate table name - ValidateTableName(tableName); - - return batch.Execute((ctx, ct) => - { - dynamic? table = null; - dynamic? autoFilter = null; - try - { - table = FindTable(ctx.Book, tableName); - - // Find column index - int columnIndex = -1; - dynamic? listColumns = null; - try - { - listColumns = table.ListColumns; - for (int i = 1; i <= listColumns.Count; i++) - { - dynamic? column = null; - try - { - column = listColumns.Item(i); - if (column.Name == columnName) - { - columnIndex = i; - break; - } - } - finally - { - ComUtilities.Release(ref column); - } - } - } - finally - { - ComUtilities.Release(ref listColumns); - } - - if (columnIndex == -1) - { - throw new InvalidOperationException($"Column '{columnName}' not found in table '{tableName}'"); - } - - // Apply filter - autoFilter = table.AutoFilter; - if (autoFilter == null) - { - // AutoFilter not enabled - enable it first - dynamic? range = null; - try - { - range = table.Range; - range.AutoFilter(Field: 1); // Enable with default - autoFilter = table.AutoFilter; - } - finally - { - ComUtilities.Release(ref range); - } - } - - // Apply filter with multiple values - // Convert List<string> to string array for COM interop - string[] valuesArray = values.ToArray(); - autoFilter.Range.AutoFilter( - Field: columnIndex, - Criteria1: valuesArray, - Operator: 7 // xlFilterValues - ); - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref autoFilter); - ComUtilities.Release(ref table); - } - }); - } - - /// <inheritdoc /> - public OperationResult ClearFilters(IExcelBatch batch, string tableName) - { - // Security: Validate table name - ValidateTableName(tableName); - - return batch.Execute((ctx, ct) => - { - dynamic? table = null; - dynamic? autoFilter = null; - try - { - table = FindTable(ctx.Book, tableName); - - autoFilter = table.AutoFilter; - if (autoFilter != null) - { - autoFilter.ShowAllData(); - } - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref autoFilter); - ComUtilities.Release(ref table); - } - }); - } - - /// <inheritdoc /> - public TableFilterResult GetFilters(IExcelBatch batch, string tableName) - { - // Security: Validate table name - ValidateTableName(tableName); - - var result = new TableFilterResult { FilePath = batch.WorkbookPath, TableName = tableName }; - return batch.Execute((ctx, ct) => - { - dynamic? table = null; - dynamic? autoFilter = null; - try - { - table = FindTable(ctx.Book, tableName); - - autoFilter = table.AutoFilter; - if (autoFilter == null) - { - result.Success = true; - result.HasActiveFilters = false; - return result; - } - - // Check each column for filters - dynamic? filters = null; - try - { - filters = autoFilter.Filters; - dynamic? listColumns = null; - try - { - listColumns = table.ListColumns; - for (int i = 1; i <= listColumns.Count; i++) - { - dynamic? column = null; - dynamic? filter = null; - try - { - column = listColumns.Item(i); - string columnName = column.Name; - - filter = filters.Item(i); - bool isFiltered = filter.On; - - if (isFiltered) - { - result.HasActiveFilters = true; - result.ColumnFilters.Add(new ColumnFilter - { - ColumnName = columnName, - ColumnIndex = i, - IsFiltered = true, - Criteria = filter.Criteria1?.ToString() ?? "", - FilterValues = [] // Could extract from Criteria1 if array - }); - } - else - { - result.ColumnFilters.Add(new ColumnFilter - { - ColumnName = columnName, - ColumnIndex = i, - IsFiltered = false - }); - } - } - finally - { - ComUtilities.Release(ref filter); - ComUtilities.Release(ref column); - } - } - } - finally - { - ComUtilities.Release(ref listColumns); - } - } - finally - { - ComUtilities.Release(ref filters); - } - - result.Success = true; - return result; - } - finally - { - ComUtilities.Release(ref autoFilter); - ComUtilities.Release(ref table); - } - }); - } -} - - - diff --git a/src/ExcelMcp.Core/Commands/Table/TableCommands.Lifecycle.cs b/src/ExcelMcp.Core/Commands/Table/TableCommands.Lifecycle.cs deleted file mode 100644 index ef498066..00000000 --- a/src/ExcelMcp.Core/Commands/Table/TableCommands.Lifecycle.cs +++ /dev/null @@ -1,381 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; -using Excel = Microsoft.Office.Interop.Excel; - -namespace Sbroenne.ExcelMcp.Core.Commands.Table; - -/// <summary> -/// Table lifecycle operations (List, Create, Rename, Delete, GetInfo) -/// </summary> -public partial class TableCommands -{ - /// <inheritdoc /> - public TableListResult List(IExcelBatch batch) - { - var result = new TableListResult { FilePath = batch.WorkbookPath }; - - return batch.Execute((ctx, ct) => - { - dynamic? sheets = null; - try - { - sheets = ctx.Book.Worksheets; - for (int i = 1; i <= sheets.Count; i++) - { - dynamic? sheet = null; - dynamic? listObjects = null; - try - { - sheet = sheets.Item(i); - listObjects = sheet.ListObjects; - string sheetName = sheet.Name; - - for (int j = 1; j <= listObjects.Count; j++) - { - dynamic? table = null; - dynamic? headerRowRange = null; - dynamic? dataBodyRange = null; - try - { - table = listObjects.Item(j); - string tableName = table.Name; - string rangeAddress = table.Range.Address; - bool showHeaders = table.ShowHeaders; - bool showTotals = table.ShowTotals; - string tableStyleName = table.TableStyle?.Name ?? ""; - - // Get column count and names - int columnCount = table.ListColumns.Count; - var columns = new List<string>(); - - if (showHeaders) - { - dynamic? listColumns = null; - try - { - listColumns = table.ListColumns; - for (int k = 1; k <= listColumns.Count; k++) - { - dynamic? column = null; - try - { - column = listColumns.Item(k); - columns.Add(column.Name); - } - finally - { - ComUtilities.Release(ref column); - } - } - } - finally - { - ComUtilities.Release(ref listColumns); - } - } - - // Get row count (excluding header) - // SECURITY FIX: DataBodyRange can be NULL if table has only headers - int rowCount = 0; - try - { - dataBodyRange = table.DataBodyRange; - if (dataBodyRange != null) - { - rowCount = dataBodyRange.Rows.Count; - } - } - finally - { - ComUtilities.Release(ref dataBodyRange); - } - - result.Tables.Add(new TableInfo - { - Name = tableName, - SheetName = sheetName, - Range = rangeAddress, - HasHeaders = showHeaders, - TableStyle = tableStyleName, - RowCount = rowCount, - ColumnCount = columnCount, - Columns = columns, - ShowTotals = showTotals - }); - } - finally - { - ComUtilities.Release(ref headerRowRange); - ComUtilities.Release(ref table); - } - } - } - finally - { - ComUtilities.Release(ref listObjects); - ComUtilities.Release(ref sheet); - } - } - result.Success = true; - return result; - } - finally - { - ComUtilities.Release(ref sheets); - } - }); - } - - /// <inheritdoc /> - public OperationResult Create(IExcelBatch batch, string sheetName, string tableName, string rangeAddress, bool hasHeaders = true, string? tableStyle = null) - { - // Security: Validate table name - ValidateTableName(tableName); - - return batch.Execute((ctx, ct) => - { - Excel.Worksheet? sheet = null; - dynamic? rangeObj = null; - dynamic? listObjects = null; - dynamic? newTable = null; - try - { - sheet = ComUtilities.FindSheet(ctx.Book, sheetName); - if (sheet == null) - { - throw new InvalidOperationException($"Sheet '{sheetName}' not found."); - } - - // Check if table name already exists - if (TableExists(ctx.Book, tableName)) - { - throw new InvalidOperationException($"Table '{tableName}' already exists"); - } - - // Get the range to convert to table - rangeObj = sheet.Range[rangeAddress]; - - // Auto-expand single cell to current region (common UX pattern) - // This allows users to specify just "A1" instead of the full range - dynamic? currentRegion = null; - try - { - // Check if single cell (no colon in address = single cell) - if (!rangeAddress.Contains(':')) - { - currentRegion = rangeObj.CurrentRegion; - if (currentRegion != null && currentRegion.Cells.Count > 1) - { - // Use the expanded current region instead - ComUtilities.Release(ref rangeObj); - rangeObj = currentRegion; - currentRegion = null; // Don't release twice - } - } - } - finally - { - ComUtilities.Release(ref currentRegion); - } - - listObjects = sheet.ListObjects; - - // Create table using numeric constant (xlSrcRange = 1) - // XlListObjectSourceType.xlSrcRange causes enum assembly loading issues - int xlSrcRange = 1; - int xlYes = 1; // xlYes for has headers - int xlGuess = 0; // xlGuess - int headerOption = hasHeaders ? xlYes : xlGuess; - - newTable = listObjects.Add(xlSrcRange, rangeObj, null, headerOption); - newTable.Name = tableName; - - // Apply table style if specified - if (!string.IsNullOrWhiteSpace(tableStyle)) - { - newTable.TableStyle = tableStyle; - } - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref newTable); - ComUtilities.Release(ref listObjects); - ComUtilities.Release(ref rangeObj); - ComUtilities.Release(ref sheet); - } - }); - } - - /// <inheritdoc /> - public OperationResult Rename(IExcelBatch batch, string tableName, string newName) - { - // Security: Validate table names - ValidateTableName(tableName); - ValidateTableName(newName); - - return batch.Execute((ctx, ct) => - { - dynamic? table = null; - try - { - table = FindTable(ctx.Book, tableName); - - // Check if new name already exists - if (TableExists(ctx.Book, newName)) - { - throw new InvalidOperationException($"Table '{newName}' already exists"); - } - - table.Name = newName; - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref table); - } - }); - } - - /// <inheritdoc /> - public OperationResult Delete(IExcelBatch batch, string tableName) - { - // Security: Validate table name - ValidateTableName(tableName); - - return batch.Execute((ctx, ct) => - { - dynamic? table = null; - dynamic? tableRange = null; - try - { - table = FindTable(ctx.Book, tableName); - - // SECURITY FIX: Store range info before Unlist() for proper cleanup - try - { - tableRange = table.Range; - } - catch (System.Runtime.InteropServices.COMException) - { - // Ignore if range is not accessible - } - - // Convert table back to range (Unlist) - table.Unlist(); - - // SECURITY FIX: After Unlist(), we must explicitly release the table COM object - // The table object is no longer valid but still holds a COM reference - ComUtilities.Release(ref table); - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref tableRange); - ComUtilities.Release(ref table); - } - }); - } - - /// <inheritdoc /> - public TableInfoResult Read(IExcelBatch batch, string tableName) - { - // Security: Validate table name - ValidateTableName(tableName); - - var result = new TableInfoResult { FilePath = batch.WorkbookPath }; - return batch.Execute((ctx, ct) => - { - dynamic? table = null; - dynamic? sheet = null; - dynamic? dataBodyRange = null; - dynamic? headerRowRange = null; - try - { - table = FindTable(ctx.Book, tableName); - - sheet = table.Parent; - string sheetName = sheet.Name; - string rangeAddress = table.Range.Address; - bool showHeaders = table.ShowHeaders; - bool showTotals = table.ShowTotals; - string tableStyleName = table.TableStyle?.Name ?? ""; - - // Get column count and names - int columnCount = table.ListColumns.Count; - var columns = new List<string>(); - - if (showHeaders) - { - dynamic? listColumns = null; - try - { - listColumns = table.ListColumns; - for (int i = 1; i <= listColumns.Count; i++) - { - dynamic? column = null; - try - { - column = listColumns.Item(i); - columns.Add(column.Name); - } - finally - { - ComUtilities.Release(ref column); - } - } - } - finally - { - ComUtilities.Release(ref listColumns); - } - } - - // Get row count (excluding header) - // SECURITY FIX: DataBodyRange can be NULL if table has only headers - int rowCount = 0; - try - { - dataBodyRange = table.DataBodyRange; - if (dataBodyRange != null) - { - rowCount = dataBodyRange.Rows.Count; - } - } - finally - { - ComUtilities.Release(ref dataBodyRange); - } - - result.Table = new TableInfo - { - Name = tableName, - SheetName = sheetName, - Range = rangeAddress, - HasHeaders = showHeaders, - TableStyle = tableStyleName, - RowCount = rowCount, - ColumnCount = columnCount, - Columns = columns, - ShowTotals = showTotals - }; - - result.Success = true; - return result; - } - finally - { - ComUtilities.Release(ref headerRowRange); - ComUtilities.Release(ref dataBodyRange); - ComUtilities.Release(ref sheet); - ComUtilities.Release(ref table); - } - }); - } -} - - - diff --git a/src/ExcelMcp.Core/Commands/Table/TableCommands.NumberFormat.cs b/src/ExcelMcp.Core/Commands/Table/TableCommands.NumberFormat.cs deleted file mode 100644 index 6fe01f5d..00000000 --- a/src/ExcelMcp.Core/Commands/Table/TableCommands.NumberFormat.cs +++ /dev/null @@ -1,200 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands.Range; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.Table; - -/// <summary> -/// TableCommands partial class - Number formatting operations -/// Delegates to RangeCommands for actual formatting operations -/// </summary> -public partial class TableCommands -{ - private readonly RangeCommands _rangeCommands = new RangeCommands(); - - // === NUMBER FORMATTING OPERATIONS === - - /// <inheritdoc /> - public RangeNumberFormatResult GetColumnNumberFormat(IExcelBatch batch, string tableName, string columnName) - { - // First, get the table's sheet name and column range - var columnRange = GetColumnRange(batch, tableName, columnName); - - if (!columnRange.Success) - { - return new RangeNumberFormatResult - { - Success = false, - ErrorMessage = columnRange.ErrorMessage, - FilePath = batch.WorkbookPath - }; - } - - // Delegate to RangeCommands to get number formats - return _rangeCommands.GetNumberFormats(batch, columnRange.SheetName, columnRange.RangeAddress); - } - - /// <inheritdoc /> - public OperationResult SetColumnNumberFormat(IExcelBatch batch, string tableName, string columnName, string formatCode) - { - // First, get the table's sheet name and column data range (excludes header) - var columnRange = GetColumnDataRange(batch, tableName, columnName); - - if (!columnRange.Success) - { - throw new InvalidOperationException(columnRange.ErrorMessage ?? "Failed to get column range"); - } - - // Delegate to RangeCommands to set number format - return _rangeCommands.SetNumberFormat(batch, columnRange.SheetName, columnRange.RangeAddress, formatCode); - } - - // === HELPER METHODS === - - /// <summary> - /// Gets the full column range (including header) for a table column - /// </summary> - private TableColumnRangeResult GetColumnRange(IExcelBatch batch, string tableName, string columnName) - { - var result = new TableColumnRangeResult - { - FilePath = batch.WorkbookPath - }; - - return batch.Execute((ctx, ct) => - { - dynamic? table = null; - dynamic? column = null; - dynamic? columnRange = null; - try - { - table = FindTable(ctx.Book, tableName); - - // Find the column - column = FindColumn(table, columnName); - if (column == null) - { - throw new InvalidOperationException($"Column '{columnName}' not found in table '{tableName}'"); - } - - // Get the entire column range (including header) - columnRange = column.Range; - - result.SheetName = columnRange.Worksheet.Name; - result.RangeAddress = columnRange.Address; - result.Success = true; - - return result; - } - finally - { - ComUtilities.Release(ref columnRange); - ComUtilities.Release(ref column); - ComUtilities.Release(ref table); - } - }); - } - - /// <summary> - /// Gets the data range (excluding header) for a table column - /// </summary> - private TableColumnRangeResult GetColumnDataRange(IExcelBatch batch, string tableName, string columnName) - { - var result = new TableColumnRangeResult - { - FilePath = batch.WorkbookPath - }; - - return batch.Execute((ctx, ct) => - { - dynamic? table = null; - dynamic? column = null; - dynamic? dataBodyRange = null; - try - { - table = FindTable(ctx.Book, tableName); - - // Find the column - column = FindColumn(table, columnName); - if (column == null) - { - throw new InvalidOperationException($"Column '{columnName}' not found in table '{tableName}'"); - } - - // Get the data body range (excludes header and totals) - dataBodyRange = column.DataBodyRange; - - if (dataBodyRange == null) - { - throw new InvalidOperationException($"Table '{tableName}' has no data rows"); - } - - result.SheetName = dataBodyRange.Worksheet.Name; - result.RangeAddress = dataBodyRange.Address; - result.Success = true; - - return result; - } - finally - { - ComUtilities.Release(ref dataBodyRange); - ComUtilities.Release(ref column); - ComUtilities.Release(ref table); - } - }); - } - - /// <summary> - /// Finds a column in a table by name - /// </summary> - private static dynamic? FindColumn(dynamic table, string columnName) - { - dynamic? columns = null; - try - { - columns = table.ListColumns; - int count = Convert.ToInt32(columns.Count); - - for (int i = 1; i <= count; i++) - { - dynamic? col = null; - try - { - col = columns.Item(i); - string name = col.Name?.ToString() ?? ""; - - if (name.Equals(columnName, StringComparison.OrdinalIgnoreCase)) - { - // Return without releasing - caller will release - var result = col; - col = null; // Prevent release in finally - return result; - } - } - finally - { - ComUtilities.Release(ref col); - } - } - - return null; - } - finally - { - ComUtilities.Release(ref columns); - } - } -} - -/// <summary> -/// Helper result for internal table column range resolution -/// </summary> -internal sealed class TableColumnRangeResult : ResultBase -{ - public string SheetName { get; set; } = string.Empty; - public string RangeAddress { get; set; } = string.Empty; -} - - - diff --git a/src/ExcelMcp.Core/Commands/Table/TableCommands.Slicers.cs b/src/ExcelMcp.Core/Commands/Table/TableCommands.Slicers.cs deleted file mode 100644 index e63f4812..00000000 --- a/src/ExcelMcp.Core/Commands/Table/TableCommands.Slicers.cs +++ /dev/null @@ -1,700 +0,0 @@ -using System.Runtime.InteropServices; - -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.Table; - -/// <summary> -/// Table slicer operations (CreateTableSlicer, ListTableSlicers, SetTableSlicerSelection, DeleteTableSlicer) -/// </summary> -public partial class TableCommands -{ - - /// <inheritdoc /> - public SlicerResult CreateTableSlicer(IExcelBatch batch, string tableName, - string columnName, string slicerName, string destinationSheet, string position) - { - // Security: Validate table name - ValidateTableName(tableName); - - return batch.Execute((ctx, ct) => - { - dynamic? table = null; - dynamic? slicerCaches = null; - dynamic? slicerCache = null; - dynamic? slicers = null; - dynamic? slicer = null; - dynamic? destSheet = null; - dynamic? destRange = null; - - try - { - table = FindTable(ctx.Book, tableName); - slicerCaches = ctx.Book.SlicerCaches; - - // Validate the column exists in the table before creating slicer - if (!TableColumnExists(table, columnName)) - { - return new SlicerResult - { - Success = false, - ErrorMessage = $"Column '{columnName}' not found in table '{tableName}'" - }; - } - - // Check if a SlicerCache already exists for this column on this table - slicerCache = FindExistingTableSlicerCache(slicerCaches, table, columnName); - - if (slicerCache == null) - { - // Create new SlicerCache for this column - // Use the deprecated SlicerCaches.Add(source, sourceField) method for Table slicers - // The Add method (without SlicerCacheType) accepts ListObject as source - // Note: Add2 does NOT accept ListObject per Microsoft documentation - slicerCache = slicerCaches.Add(table, columnName); - } - - // Get destination sheet and calculate position from cell reference - destSheet = ctx.Book.Worksheets[destinationSheet]; - destRange = destSheet.Range[position]; - - // Get position in points from the cell reference - double top = Convert.ToDouble(destRange.Top); - double left = Convert.ToDouble(destRange.Left); - - // Add visual Slicer to the cache - // Slicers.Add(SlicerDestination, Level, Name, Caption, Top, Left, Width, Height) - // For non-OLAP sources, Level should be Type.Missing or omitted - slicers = slicerCache.Slicers; - slicer = slicers.Add(destSheet, Type.Missing, slicerName, slicerName, top, left); - - // Build result - var result = BuildTableSlicerResult(slicer, slicerCache, columnName, tableName); - result.Success = true; - result.WorkflowHint = $"Slicer '{slicerName}' created for column '{columnName}' in table '{tableName}'. Use SetTableSlicerSelection to filter data."; - - return result; - } - finally - { - ComUtilities.Release(ref destRange); - ComUtilities.Release(ref destSheet); - ComUtilities.Release(ref slicer); - ComUtilities.Release(ref slicers); - ComUtilities.Release(ref slicerCache); - ComUtilities.Release(ref slicerCaches); - ComUtilities.Release(ref table); - } - }); - } - - /// <inheritdoc /> - public SlicerListResult ListTableSlicers(IExcelBatch batch, string? tableName = null) - { - // Security: Validate table name if provided - if (!string.IsNullOrEmpty(tableName)) - { - ValidateTableName(tableName); - } - - return batch.Execute((ctx, ct) => - { - var result = new SlicerListResult { Success = true }; - dynamic? slicerCaches = null; - dynamic? targetTable = null; - - try - { - slicerCaches = ctx.Book.SlicerCaches; - - // If filtering by Table, find it first - if (!string.IsNullOrEmpty(tableName)) - { - targetTable = FindTable(ctx.Book, tableName); - } - - for (int cacheIndex = 1; cacheIndex <= slicerCaches.Count; cacheIndex++) - { - dynamic? cache = null; - dynamic? slicers = null; - - try - { - cache = slicerCaches.Item(cacheIndex); - - // Check if this is a Table slicer using the List boolean property - // SlicerCache.List returns true if the slicer is connected to a ListObject (Table) - bool isTableSlicer = false; - try - { - isTableSlicer = cache.List == true; - } - catch (COMException) - { - // List property not available - not a Table slicer - isTableSlicer = false; - } - - if (!isTableSlicer) - { - continue; // Skip non-Table slicers (e.g., PivotTable slicers) - } - - // If filtering by Table, check if this cache is connected to it - if (targetTable != null && !IsSlicerCacheConnectedToTable(cache, targetTable)) - { - continue; - } - - // Get the connected table name - string connectedTableName = GetSlicerCacheTableName(cache); - - slicers = cache.Slicers; - for (int slicerIndex = 1; slicerIndex <= slicers.Count; slicerIndex++) - { - dynamic? slicer = null; - try - { - slicer = slicers.Item(slicerIndex); - var slicerInfo = BuildTableSlicerInfo(slicer, cache, connectedTableName); - result.Slicers.Add(slicerInfo); - } - finally - { - ComUtilities.Release(ref slicer); - } - } - } - finally - { - ComUtilities.Release(ref slicers); - ComUtilities.Release(ref cache); - } - } - - return result; - } - finally - { - ComUtilities.Release(ref targetTable); - ComUtilities.Release(ref slicerCaches); - } - }); - } - - /// <inheritdoc /> - public SlicerResult SetTableSlicerSelection(IExcelBatch batch, string slicerName, - List<string> selectedItems, bool clearFirst = true) - { - return batch.Execute((ctx, ct) => - { - dynamic? slicerCaches = null; - dynamic? targetCache = null; - dynamic? targetSlicer = null; - dynamic? slicerItems = null; - - try - { - slicerCaches = ctx.Book.SlicerCaches; - - // Find the slicer by name (searching Table slicers only) - var searchResult = FindTableSlicerByName(slicerCaches, slicerName); - targetCache = searchResult.Cache; - targetSlicer = searchResult.Slicer; - - if (targetSlicer == null || targetCache == null) - { - return new SlicerResult - { - Success = false, - ErrorMessage = $"Table slicer '{slicerName}' not found in workbook" - }; - } - - // Get slicer items from the cache - slicerItems = targetCache.SlicerItems; - - // Build set of items to select for fast lookup - var itemsToSelect = new HashSet<string>(selectedItems, StringComparer.OrdinalIgnoreCase); - - // If no items specified, select all (clear filter) - bool selectAll = selectedItems.Count == 0; - - // Iterate through slicer items and set selection - for (int i = 1; i <= slicerItems.Count; i++) - { - dynamic? item = null; - try - { - item = slicerItems.Item(i); - string itemName = item.Name?.ToString() ?? string.Empty; - - if (selectAll) - { - item.Selected = true; - } - else if (clearFirst) - { - // Clear first mode: select only specified items - item.Selected = itemsToSelect.Contains(itemName); - } - else - { - // Additive mode: add to existing selection - if (itemsToSelect.Contains(itemName)) - { - item.Selected = true; - } - } - } - finally - { - ComUtilities.Release(ref item); - } - } - - // Build result with updated state - string columnName = GetSlicerCacheColumnName(targetCache); - string connectedTableName = GetSlicerCacheTableName(targetCache); - var result = BuildTableSlicerResult(targetSlicer, targetCache, columnName, connectedTableName); - result.Success = true; - result.WorkflowHint = selectAll - ? $"Table slicer '{slicerName}' filter cleared - all items are now visible." - : $"Table slicer '{slicerName}' selection updated to {selectedItems.Count} item(s)."; - - return result; - } - finally - { - ComUtilities.Release(ref slicerItems); - ComUtilities.Release(ref targetSlicer); - ComUtilities.Release(ref targetCache); - ComUtilities.Release(ref slicerCaches); - } - }); - } - - /// <inheritdoc /> - public OperationResult DeleteTableSlicer(IExcelBatch batch, string slicerName) - { - return batch.Execute((ctx, ct) => - { - dynamic? slicerCaches = null; - dynamic? targetCache = null; - dynamic? targetSlicer = null; - - try - { - slicerCaches = ctx.Book.SlicerCaches; - - // Find the slicer by name (searching Table slicers only) - var searchResult = FindTableSlicerByName(slicerCaches, slicerName); - targetCache = searchResult.Cache; - targetSlicer = searchResult.Slicer; - - if (targetSlicer == null) - { - return new OperationResult - { - Success = false, - ErrorMessage = $"Table slicer '{slicerName}' not found in workbook" - }; - } - - // Delete the visual slicer - targetSlicer.Delete(); - - // Note: The SlicerCache will be automatically deleted if this was the last slicer - // connected to it. Excel handles this automatically. - - return new OperationResult { Success = true }; - } - finally - { - ComUtilities.Release(ref targetSlicer); - ComUtilities.Release(ref targetCache); - ComUtilities.Release(ref slicerCaches); - } - }); - } - - #region Table Slicer Helper Methods - - /// <summary> - /// Checks if a column with the given name exists in the table - /// </summary> - /// <param name="table">The table (ListObject) to check</param> - /// <param name="columnName">The column name to search for</param> - /// <returns>True if the column exists, false otherwise</returns> - private static bool TableColumnExists(dynamic table, string columnName) - { - dynamic? listColumns = null; - try - { - listColumns = table.ListColumns; - for (int i = 1; i <= listColumns.Count; i++) - { - dynamic? column = null; - try - { - column = listColumns.Item(i); - string name = column.Name?.ToString() ?? string.Empty; - if (string.Equals(name, columnName, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - finally - { - ComUtilities.Release(ref column); - } - } - return false; - } - finally - { - ComUtilities.Release(ref listColumns); - } - } - - /// <summary> - /// Result of searching for a table slicer by name - /// </summary> - private readonly struct TableSlicerSearchResult - { - public dynamic? Cache { get; init; } - public dynamic? Slicer { get; init; } - } - - /// <summary> - /// Finds an existing SlicerCache for a column on a specific Table - /// </summary> - private static dynamic? FindExistingTableSlicerCache(dynamic slicerCaches, dynamic table, string columnName) - { - for (int i = 1; i <= slicerCaches.Count; i++) - { - dynamic? cache = null; - try - { - cache = slicerCaches.Item(i); - - // Check if this is a Table slicer using the List boolean property - bool isTableSlicer = false; - try - { - isTableSlicer = cache.List == true; - } - catch (COMException) - { - // List property doesn't exist on PivotTable slicer caches - isTableSlicer = false; - } - - if (!isTableSlicer) - { - ComUtilities.Release(ref cache); - continue; - } - - // Check if cache is for the same column - string cacheColumnName = GetSlicerCacheColumnName(cache); - if (!string.Equals(cacheColumnName, columnName, StringComparison.OrdinalIgnoreCase)) - { - ComUtilities.Release(ref cache); - continue; - } - - // Check if this cache is connected to our Table - if (IsSlicerCacheConnectedToTable(cache, table)) - { - return cache; // Don't release - returning to caller - } - - ComUtilities.Release(ref cache); - } - catch (COMException) - { - // COM access may fail for certain cache types - continue searching - ComUtilities.Release(ref cache); - } - } - - return null; - } - - /// <summary> - /// Gets the source column name from a Table SlicerCache. - /// Precondition: cache.List == true (verified by caller) - /// </summary> - private static string GetSlicerCacheColumnName(dynamic cache) - { - // Per MS docs: SourceName returns the field name for table slicers - // https://learn.microsoft.com/en-us/office/vba/api/excel.slicercache.sourcename - string? sourceName = cache.SourceName?.ToString(); - if (!string.IsNullOrEmpty(sourceName)) - return sourceName; - - // Fallback: parse from cache name (usually "Slicer_ColumnName" format) - string cacheName = cache.Name?.ToString() ?? string.Empty; - if (cacheName.StartsWith("Slicer_", StringComparison.OrdinalIgnoreCase)) - { - return cacheName[7..]; // Remove "Slicer_" prefix - } - return cacheName; - } - - /// <summary> - /// Gets the connected Table name from a SlicerCache. - /// Precondition: cache.List == true (verified by caller) - /// </summary> - private static string GetSlicerCacheTableName(dynamic cache) - { - // Per MS docs: ListObject returns the Table for table slicers - // https://learn.microsoft.com/en-us/office/vba/api/excel.slicercache.listobject - dynamic? listObject = null; - try - { - listObject = cache.ListObject; - return listObject?.Name?.ToString() ?? string.Empty; - } - finally - { - ComUtilities.Release(ref listObject); - } - } - - /// <summary> - /// Checks if a SlicerCache is connected to a specific Table. - /// Precondition: cache.List == true (verified by caller) - /// </summary> - private static bool IsSlicerCacheConnectedToTable(dynamic cache, dynamic table) - { - // Per MS docs: ListObject returns the Table for table slicers - dynamic? cacheListObject = null; - try - { - cacheListObject = cache.ListObject; - if (cacheListObject == null) - return false; - - string cacheTableName = cacheListObject.Name?.ToString() ?? string.Empty; - string targetTableName = table.Name?.ToString() ?? string.Empty; - - return string.Equals(cacheTableName, targetTableName, StringComparison.OrdinalIgnoreCase); - } - finally - { - ComUtilities.Release(ref cacheListObject); - } - } - - /// <summary> - /// Finds a Table slicer by name - /// </summary> - private static TableSlicerSearchResult FindTableSlicerByName(dynamic slicerCaches, string slicerName) - { - for (int cacheIndex = 1; cacheIndex <= slicerCaches.Count; cacheIndex++) - { - dynamic? cache = null; - dynamic? slicers = null; - - try - { - cache = slicerCaches.Item(cacheIndex); - - // Check if this is a Table slicer using the List boolean property - bool isTableSlicer = false; - try - { - isTableSlicer = cache.List == true; - } - catch (COMException) - { - // List property doesn't exist on PivotTable slicer caches - isTableSlicer = false; - } - - if (!isTableSlicer) - { - ComUtilities.Release(ref cache); - continue; - } - - slicers = cache.Slicers; - for (int slicerIndex = 1; slicerIndex <= slicers.Count; slicerIndex++) - { - dynamic? slicer = null; - try - { - slicer = slicers.Item(slicerIndex); - string name = slicer.Name?.ToString() ?? string.Empty; - - if (string.Equals(name, slicerName, StringComparison.OrdinalIgnoreCase)) - { - // Don't release cache and slicer - returning to caller - ComUtilities.Release(ref slicers); - return new TableSlicerSearchResult { Cache = cache, Slicer = slicer }; - } - - ComUtilities.Release(ref slicer); - } - catch (COMException) - { - // COM access failed for this slicer, continue searching - ComUtilities.Release(ref slicer); - } - } - - ComUtilities.Release(ref slicers); - ComUtilities.Release(ref cache); - } - catch (COMException) - { - // COM access failed for this cache, continue searching - ComUtilities.Release(ref slicers); - ComUtilities.Release(ref cache); - } - } - - return new TableSlicerSearchResult { Cache = null, Slicer = null }; - } - - /// <summary> - /// Builds a SlicerInfo object for a Table slicer - /// </summary> - private static SlicerInfo BuildTableSlicerInfo(dynamic slicer, dynamic cache, string tableName) - { - dynamic? parent = null; - dynamic? slicerItems = null; - - try - { - parent = slicer.Parent; - slicerItems = cache.SlicerItems; - - var info = new SlicerInfo - { - Name = slicer.Name?.ToString() ?? string.Empty, - Caption = slicer.Caption?.ToString() ?? string.Empty, - FieldName = GetSlicerCacheColumnName(cache), - SheetName = parent?.Name?.ToString() ?? string.Empty, - Position = GetSlicerPosition(slicer), - ColumnCount = Convert.ToInt32(slicer.NumberOfColumns ?? 1), - ConnectedTable = tableName, - SourceType = "Table" - }; - - // Collect selected and available items - for (int i = 1; i <= slicerItems.Count; i++) - { - dynamic? item = null; - try - { - item = slicerItems.Item(i); - string itemName = item.Name?.ToString() ?? string.Empty; - info.AvailableItems.Add(itemName); - - bool isSelected = item.Selected; - if (isSelected) - { - info.SelectedItems.Add(itemName); - } - } - finally - { - ComUtilities.Release(ref item); - } - } - - return info; - } - finally - { - ComUtilities.Release(ref slicerItems); - ComUtilities.Release(ref parent); - } - } - - /// <summary> - /// Builds a SlicerResult object for a Table slicer operation - /// </summary> - private static SlicerResult BuildTableSlicerResult(dynamic slicer, dynamic cache, string columnName, string tableName) - { - dynamic? parent = null; - dynamic? slicerItems = null; - - try - { - parent = slicer.Parent; - slicerItems = cache.SlicerItems; - - var result = new SlicerResult - { - Name = slicer.Name?.ToString() ?? string.Empty, - Caption = slicer.Caption?.ToString() ?? string.Empty, - FieldName = columnName, - SheetName = parent?.Name?.ToString() ?? string.Empty, - Position = GetSlicerPosition(slicer), - ConnectedTable = tableName, - SourceType = "Table" - }; - - // Collect selected and available items - for (int i = 1; i <= slicerItems.Count; i++) - { - dynamic? item = null; - try - { - item = slicerItems.Item(i); - string itemName = item.Name?.ToString() ?? string.Empty; - result.AvailableItems.Add(itemName); - - bool isSelected = item.Selected; - if (isSelected) - { - result.SelectedItems.Add(itemName); - } - } - finally - { - ComUtilities.Release(ref item); - } - } - - return result; - } - finally - { - ComUtilities.Release(ref slicerItems); - ComUtilities.Release(ref parent); - } - } - - /// <summary> - /// Gets the position string for a slicer (e.g., "H2") - /// </summary> - private static string GetSlicerPosition(dynamic slicer) - { - // Per Microsoft docs: TopLeftCell is on Shape object, not Slicer - // https://learn.microsoft.com/en-us/office/vba/api/excel.shape.topleftcell - dynamic? shape = null; - dynamic? topLeftCell = null; - try - { - shape = slicer.Shape; - topLeftCell = shape.TopLeftCell; - return topLeftCell?.Address?.ToString()?.Replace("$", "") ?? string.Empty; - } - finally - { - ComUtilities.Release(ref topLeftCell); - ComUtilities.Release(ref shape); - } - } - - #endregion -} - - diff --git a/src/ExcelMcp.Core/Commands/Table/TableCommands.Sort.cs b/src/ExcelMcp.Core/Commands/Table/TableCommands.Sort.cs deleted file mode 100644 index 251c2a71..00000000 --- a/src/ExcelMcp.Core/Commands/Table/TableCommands.Sort.cs +++ /dev/null @@ -1,185 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.Table; - -/// <summary> -/// TableCommands partial class - Sort operations -/// </summary> -public partial class TableCommands -{ - // Excel constants for sorting - private const int xlYes = 1; - private const int xlAscending = 1; - private const int xlDescending = 2; - - /// <summary> - /// Sorts a table by a single column - /// </summary> - public OperationResult Sort( - IExcelBatch batch, - string tableName, - string columnName, - bool ascending = true) - { - // Security: Validate table name - ValidateTableName(tableName); - - return batch.Execute((ctx, ct) => - { - dynamic? table = null; - dynamic? columns = null; - dynamic? column = null; - dynamic? sortRange = null; - dynamic? columnRange = null; - - try - { - table = FindTable(ctx.Book, tableName); - - // Find column - columns = table.ListColumns; - column = columns.Item(columnName); - if (column == null) - { - throw new InvalidOperationException($"Column '{columnName}' not found in table '{tableName}'"); - } - - // Get ranges for sorting - sortRange = table.Range; - columnRange = column.Range; - - // Perform sort - sortRange.Sort( - Key1: columnRange, - Order1: ascending ? xlAscending : xlDescending, - Header: xlYes - ); - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref columnRange); - ComUtilities.Release(ref sortRange); - ComUtilities.Release(ref column); - ComUtilities.Release(ref columns); - ComUtilities.Release(ref table); - } - }); - } - - /// <inheritdoc /> - public OperationResult SortMulti( - IExcelBatch batch, - string tableName, - List<TableSortColumn> sortColumns) - { - // Security: Validate table name - ValidateTableName(tableName); - - return batch.Execute((ctx, ct) => - { - if (sortColumns == null || sortColumns.Count == 0) - { - throw new ArgumentException("At least one sort column must be specified", nameof(sortColumns)); - } - - if (sortColumns.Count > 3) - { - throw new ArgumentException("Excel supports a maximum of 3 sort levels", nameof(sortColumns)); - } - - dynamic? table = null; - dynamic? sortRange = null; - dynamic? key1 = null, key2 = null, key3 = null; - - try - { - table = FindTable(ctx.Book, tableName); - - sortRange = table.Range; - dynamic? columns = null; - try - { - columns = table.ListColumns; - - // Get column ranges - for (int i = 0; i < sortColumns.Count; i++) - { - dynamic? col = null; - try - { - col = columns.Item(sortColumns[i].ColumnName); - if (col == null) - { - throw new InvalidOperationException($"Column '{sortColumns[i].ColumnName}' not found in table '{tableName}'"); - } - - if (i == 0) key1 = col.Range; - else if (i == 1) key2 = col.Range; - else if (i == 2) key3 = col.Range; - } - finally - { - ComUtilities.Release(ref col); - } - } - } - finally - { - ComUtilities.Release(ref columns); - } - - // Perform sort based on number of columns - if (sortColumns.Count == 1) - { - sortRange.Sort( - Key1: key1, - Order1: sortColumns[0].Ascending ? xlAscending : xlDescending, - Header: xlYes - ); - } - else if (sortColumns.Count == 2) - { - sortRange.Sort( - Key1: key1, - Order1: sortColumns[0].Ascending ? xlAscending : xlDescending, - Key2: key2, - Order2: sortColumns[1].Ascending ? xlAscending : xlDescending, - Header: xlYes - ); - } - else if (sortColumns.Count == 3) - { - sortRange.Sort( - Key1: key1, - Order1: sortColumns[0].Ascending ? xlAscending : xlDescending, - Key2: key2, - Order2: sortColumns[1].Ascending ? xlAscending : xlDescending, - Key3: key3, - Order3: sortColumns[2].Ascending ? xlAscending : xlDescending, - Header: xlYes - ); - } - - // Build description - var sortDesc = string.Join(", ", sortColumns.Select(sc => $"{sc.ColumnName} ({(sc.Ascending ? "asc" : "desc")})")); - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref key3); - ComUtilities.Release(ref key2); - ComUtilities.Release(ref key1); - ComUtilities.Release(ref sortRange); - ComUtilities.Release(ref table); - } - }); - } -} - - - diff --git a/src/ExcelMcp.Core/Commands/Table/TableCommands.Structure.cs b/src/ExcelMcp.Core/Commands/Table/TableCommands.Structure.cs deleted file mode 100644 index ee3ea57e..00000000 --- a/src/ExcelMcp.Core/Commands/Table/TableCommands.Structure.cs +++ /dev/null @@ -1,175 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.Table; - -/// <summary> -/// Table structure operations (Resize, ToggleTotals, SetColumnTotal, SetStyle) -/// </summary> -public partial class TableCommands -{ - /// <inheritdoc /> - public OperationResult Resize(IExcelBatch batch, string tableName, string newRange) - { - // Security: Validate table name - ValidateTableName(tableName); - - return batch.Execute((ctx, ct) => - { - dynamic? table = null; - dynamic? sheet = null; - dynamic? newRangeObj = null; - try - { - table = FindTable(ctx.Book, tableName); - - sheet = table.Parent; - newRangeObj = sheet.Range[newRange]; - - // Resize the table - table.Resize(newRangeObj); - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref newRangeObj); - ComUtilities.Release(ref sheet); - ComUtilities.Release(ref table); - } - }); - } - - /// <inheritdoc /> - public OperationResult ToggleTotals(IExcelBatch batch, string tableName, bool showTotals) - { - // Security: Validate table name - ValidateTableName(tableName); - - return batch.Execute((ctx, ct) => - { - dynamic? table = null; - try - { - table = FindTable(ctx.Book, tableName); - - table.ShowTotals = showTotals; - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref table); - } - }); - } - - /// <inheritdoc /> - public OperationResult SetColumnTotal(IExcelBatch batch, string tableName, string columnName, string totalFunction) - { - // Security: Validate table name - ValidateTableName(tableName); - - return batch.Execute((ctx, ct) => - { - dynamic? table = null; - dynamic? listColumns = null; - dynamic? column = null; - try - { - table = FindTable(ctx.Book, tableName); - - // Ensure totals row is shown - if (!table.ShowTotals) - { - table.ShowTotals = true; - } - - // Find the column - listColumns = table.ListColumns; - column = null; - for (int i = 1; i <= listColumns.Count; i++) - { - dynamic? col = null; - try - { - col = listColumns.Item(i); - if (col.Name == columnName) - { - column = col; - break; - } - } - finally - { - if (col != null && col.Name != columnName) - { - ComUtilities.Release(ref col); - } - } - } - - if (column == null) - { - throw new InvalidOperationException($"Column '{columnName}' not found in table '{tableName}'"); - } - - // Map function name to Excel constant - // xlTotalsCalculationSum = 1, xlTotalsCalculationAverage = 2, xlTotalsCalculationCount = 3, - // xlTotalsCalculationCountNums = 4, xlTotalsCalculationMax = 5, xlTotalsCalculationMin = 6, - // xlTotalsCalculationStdDev = 7, xlTotalsCalculationVar = 9, xlTotalsCalculationNone = 0 - int xlFunction = totalFunction.ToLowerInvariant() switch - { - "sum" => 1, - "average" or "avg" => 2, - "count" => 3, - "countnums" => 4, - "max" => 5, - "min" => 6, - "stddev" => 7, - "var" => 9, - "none" => 0, - _ => throw new ArgumentException($"Unknown total function '{totalFunction}'. Valid: sum, average, count, countnums, max, min, stddev, var, none") - }; - - column.TotalsCalculation = xlFunction; - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref column); - ComUtilities.Release(ref listColumns); - ComUtilities.Release(ref table); - } - }); - } - - /// <inheritdoc /> - public OperationResult SetStyle(IExcelBatch batch, string tableName, string tableStyle) - { - // Security: Validate table name - ValidateTableName(tableName); - - return batch.Execute((ctx, ct) => - { - dynamic? table = null; - try - { - table = FindTable(ctx.Book, tableName); - - table.TableStyle = tableStyle; - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - finally - { - ComUtilities.Release(ref table); - } - }); - } -} - - - diff --git a/src/ExcelMcp.Core/Commands/Table/TableCommands.StructuredReferences.cs b/src/ExcelMcp.Core/Commands/Table/TableCommands.StructuredReferences.cs deleted file mode 100644 index 8ba9c49c..00000000 --- a/src/ExcelMcp.Core/Commands/Table/TableCommands.StructuredReferences.cs +++ /dev/null @@ -1,170 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.Table; - -/// <summary> -/// TableCommands partial class - Structured Reference operations -/// </summary> -public partial class TableCommands -{ - /// <summary> - /// Gets structured reference information for a table region or column - /// </summary> - public TableStructuredReferenceResult GetStructuredReference( - IExcelBatch batch, - string tableName, - TableRegion region, - string? columnName = null) - { - // Security: Validate table name - ValidateTableName(tableName); - - var result = new TableStructuredReferenceResult { FilePath = batch.WorkbookPath }; - return batch.Execute((ctx, ct) => - { - dynamic? table = null; - try - { - table = FindTable(ctx.Book, tableName); - - result.TableName = tableName; - result.Region = region; - result.ColumnName = columnName; - - // Get sheet name - dynamic? range = null; - dynamic? sheet = null; - try - { - range = table.Range; - sheet = range.Worksheet; - result.SheetName = sheet.Name; - } - finally - { - ComUtilities.Release(ref sheet); - ComUtilities.Release(ref range); - } - - // Build structured reference - result.StructuredReference = BuildStructuredReference(tableName, region, columnName); - - // Get region range - dynamic? regionRange = null; - try - { - regionRange = GetRegionRange(table, region, columnName); - result.RangeAddress = regionRange.Address; - result.RowCount = regionRange.Rows.Count; - result.ColumnCount = regionRange.Columns.Count; - } - finally - { - ComUtilities.Release(ref regionRange); - } - - result.Success = true; - - return result; - } - finally - { - ComUtilities.Release(ref table); - } - }); - } - - /// <summary> - /// Builds a structured reference formula string for a table region - /// </summary> - private static string BuildStructuredReference(string tableName, TableRegion region, string? columnName) - { - if (!string.IsNullOrEmpty(columnName)) - { - // Column-specific reference - return region switch - { - TableRegion.All => $"{tableName}[[#All],[{columnName}]]", - TableRegion.Data => $"{tableName}[[{columnName}]]", // Default is data - TableRegion.Headers => $"{tableName}[[#Headers],[{columnName}]]", - TableRegion.Totals => $"{tableName}[[#Totals],[{columnName}]]", - TableRegion.ThisRow => $"{tableName}[[@],[{columnName}]]", - _ => $"{tableName}[[{columnName}]]" - }; - } - else - { - // Entire region reference - return region switch - { - TableRegion.All => $"{tableName}[#All]", - TableRegion.Data => $"{tableName}[#Data]", - TableRegion.Headers => $"{tableName}[#Headers]", - TableRegion.Totals => $"{tableName}[#Totals]", - TableRegion.ThisRow => $"{tableName}[@]", - _ => $"{tableName}[#All]" - }; - } - } - - /// <summary> - /// Gets the Excel Range object for a specific table region - /// </summary> - private static dynamic GetRegionRange(dynamic table, TableRegion region, string? columnName) - { - dynamic regionRange = region switch - { - TableRegion.All => table.Range, - TableRegion.Data => table.DataBodyRange, - TableRegion.Headers => table.HeaderRowRange, - TableRegion.Totals => table.TotalsRowRange, - TableRegion.ThisRow => table.Range, // Full range for ThisRow context - _ => table.Range - }; - - // If column specified, get intersection with column - if (!string.IsNullOrEmpty(columnName)) - { - dynamic? columns = null; - dynamic? column = null; - try - { - columns = table.ListColumns; - column = columns.Item(columnName); - dynamic columnRange = column.Range; - - // Intersect with region range - dynamic? app = null; - dynamic? intersection = null; - try - { - app = table.Application; - intersection = app.Intersect(regionRange, columnRange); - return intersection; // Return intersection - } - catch (System.Runtime.InteropServices.COMException) - { - // If intersection fails, return column range - return columnRange; - } - finally - { - ComUtilities.Release(ref app); - // Don't release intersection here - caller will do it - } - } - finally - { - ComUtilities.Release(ref column); - ComUtilities.Release(ref columns); - } - } - - return regionRange; // Return region range directly - } -} - - - diff --git a/src/ExcelMcp.Core/Commands/Table/TableCommands.cs b/src/ExcelMcp.Core/Commands/Table/TableCommands.cs deleted file mode 100644 index 01787bb7..00000000 --- a/src/ExcelMcp.Core/Commands/Table/TableCommands.cs +++ /dev/null @@ -1,87 +0,0 @@ -using System.Text.RegularExpressions; - -namespace Sbroenne.ExcelMcp.Core.Commands.Table; - -/// <summary> -/// Excel Table (ListObject) management commands - main partial class with shared state and helper methods -/// </summary> -public partial class TableCommands : ITableCommands, ITableColumnCommands -{ - #region Constants and Validation - - /// <summary> - /// Regex pattern for valid table names - /// </summary> - private static readonly Regex TableNameRegex = new(@"^[a-zA-Z_][a-zA-Z0-9_]*$", RegexOptions.Compiled); - - /// <summary> - /// Maximum allowed table name length - /// </summary> - private const int MaxTableNameLength = 255; - - /// <summary> - /// Validates a table name to prevent injection attacks and ensure Excel compatibility - /// </summary> - /// <param name="tableName">Table name to validate</param> - /// <exception cref="ArgumentException">Thrown if table name is invalid</exception> - private static void ValidateTableName(string tableName) - { - if (string.IsNullOrWhiteSpace(tableName)) - { - throw new ArgumentException("Table name cannot be null or empty", nameof(tableName)); - } - - if (tableName.Length > MaxTableNameLength) - { - throw new ArgumentException( - $"Table name too long: {tableName.Length} characters (maximum: {MaxTableNameLength})", - nameof(tableName)); - } - - if (!TableNameRegex.IsMatch(tableName)) - { - throw new ArgumentException( - $"Invalid table name '{tableName}'. Table names must start with a letter or underscore, " + - "and can only contain letters, numbers, and underscores (no spaces or special characters).", - nameof(tableName)); - } - - // Check for reserved names - string upperName = tableName.ToUpperInvariant(); - if (upperName == "PRINT_AREA" || upperName == "PRINT_TITLES" || - upperName == "_XLNM" || upperName.StartsWith("_XLNM.", StringComparison.Ordinal)) - { - throw new ArgumentException( - $"Table name '{tableName}' is reserved by Excel", - nameof(tableName)); - } - } - - #endregion - - #region Helper Methods - - /// <summary> - /// Finds a table by name in the workbook, throwing if not found. - /// Delegates to CoreLookupHelpers.FindTable for the actual lookup. - /// </summary> - /// <param name="workbook">The workbook to search</param> - /// <param name="tableName">Name of the table to find</param> - /// <returns>The table object (caller must release)</returns> - /// <exception cref="InvalidOperationException">Thrown if table is not found</exception> - private static dynamic FindTable(dynamic workbook, string tableName) - => CoreLookupHelpers.FindTable(workbook, tableName); - - /// <summary> - /// Checks if a table with the given name exists in the workbook - /// </summary> - /// <param name="workbook">The workbook to search</param> - /// <param name="tableName">Name of the table to check</param> - /// <returns>True if table exists, false otherwise</returns> - private static bool TableExists(dynamic workbook, string tableName) - => CoreLookupHelpers.TableExists(workbook, tableName); - - #endregion -} - - diff --git a/src/ExcelMcp.Core/Commands/Vba/IVbaCommands.cs b/src/ExcelMcp.Core/Commands/Vba/IVbaCommands.cs deleted file mode 100644 index 0ffb3e5b..00000000 --- a/src/ExcelMcp.Core/Commands/Vba/IVbaCommands.cs +++ /dev/null @@ -1,68 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Attributes; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// VBA scripts (requires .xlsm and VBA trust enabled). -/// -/// PREREQUISITES: -/// - Workbook must be macro-enabled (.xlsm) -/// - VBA trust must be enabled for automation -/// -/// RUN: procedureName format is 'Module.Procedure' (e.g., 'Module1.MySub'). -/// </summary> -[ServiceCategory("vba", "Vba")] -[McpTool("vba", Title = "VBA Operations", Destructive = true, Category = "automation", - Description = "VBA scripts (requires .xlsm and VBA trust enabled). Manages VBA macro operations, code import/export, and script execution in macro-enabled workbooks. Prerequisites: Use setup-vba-trust to configure VBA trust for automation.")] -public interface IVbaCommands -{ - /// <summary> - /// Lists all VBA modules and procedures in the workbook - /// </summary> - [ServiceAction("list")] - VbaListResult List(IExcelBatch batch); - - /// <summary> - /// Views VBA module code without exporting to file - /// </summary> - /// <param name="moduleName">Name of the VBA module</param> - [ServiceAction("view")] - VbaViewResult View(IExcelBatch batch, [RequiredParameter] string moduleName); - - /// <summary> - /// Imports VBA code to create a new module - /// </summary> - /// <param name="moduleName">Name for the new module</param> - /// <param name="vbaCode">VBA code to import</param> - [ServiceAction("import")] - OperationResult Import(IExcelBatch batch, [RequiredParameter] string moduleName, [RequiredParameter][FileOrValue] string vbaCode); - - /// <summary> - /// Updates an existing VBA module with new code - /// </summary> - /// <param name="moduleName">Name of the module to update</param> - /// <param name="vbaCode">New VBA code</param> - [ServiceAction("update")] - OperationResult Update(IExcelBatch batch, [RequiredParameter] string moduleName, [RequiredParameter][FileOrValue] string vbaCode); - - /// <summary> - /// Runs a VBA procedure with optional parameters - /// </summary> - /// <param name="procedureName">Name of the procedure to run (e.g., "Module1.MySub")</param> - /// <param name="timeout">Optional timeout for execution</param> - /// <param name="parameters">Optional parameters to pass to the procedure</param> - [ServiceAction("run")] - OperationResult Run(IExcelBatch batch, [RequiredParameter] string procedureName, TimeSpan? timeout, params string[] parameters); - - /// <summary> - /// Deletes a VBA module - /// </summary> - /// <param name="moduleName">Name of the module to delete</param> - [ServiceAction("delete")] - OperationResult Delete(IExcelBatch batch, [RequiredParameter] string moduleName); -} - - - diff --git a/src/ExcelMcp.Core/Commands/Vba/VbaCommands.Lifecycle.cs b/src/ExcelMcp.Core/Commands/Vba/VbaCommands.Lifecycle.cs deleted file mode 100644 index 5080e3c9..00000000 --- a/src/ExcelMcp.Core/Commands/Vba/VbaCommands.Lifecycle.cs +++ /dev/null @@ -1,373 +0,0 @@ -using System.Runtime.InteropServices; -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// VBA script lifecycle operations (List, View, Export, Import, Update, Delete) -/// </summary> -public partial class VbaCommands -{ - /// <inheritdoc /> - public VbaListResult List(IExcelBatch batch) - { - var result = new VbaListResult { FilePath = batch.WorkbookPath }; - - var (isValid, validationError) = ValidateVbaFile(batch.WorkbookPath); - if (!isValid) - { - // For LLM-friendly behavior: .xlsx files don't support VBA, return empty list instead of error - result.Success = true; - result.Scripts = []; - return result; - } - - // Check VBA trust BEFORE attempting operation - if (!IsVbaTrustEnabled()) - { - throw new InvalidOperationException(VbaTrustErrorMessage); - } - - return batch.Execute((ctx, ct) => - { - dynamic? vbaProject = null; - dynamic? vbComponents = null; - try - { - // PIA gap: VBProject is in Microsoft.Vbe.Interop, not the Excel PIA. - // No .NET 5+ compatible NuGet package exists for VBE types (ThammimTech.Microsoft.Vbe.Interop targets .NET Framework only). - vbaProject = ((dynamic)ctx.Book).VBProject; - vbComponents = vbaProject.VBComponents; - - for (int i = 1; i <= vbComponents.Count; i++) - { - dynamic? component = null; - dynamic? codeModule = null; - try - { - component = vbComponents.Item(i); - string name = component.Name; - int type = component.Type; - - string typeStr = GetVbaModuleTypeName(type); - - var procedures = new List<string>(); - codeModule = component.CodeModule; - int moduleLineCount = codeModule.CountOfLines; - - // Parse procedures from code - for (int line = 1; line <= moduleLineCount; line++) - { - string codeLine = codeModule.Lines[line, 1]; - string trimmedLine = codeLine.TrimStart(); - if (trimmedLine.StartsWith("Sub ", StringComparison.Ordinal) || - trimmedLine.StartsWith("Function ", StringComparison.Ordinal) || - trimmedLine.StartsWith("Public Sub ", StringComparison.Ordinal) || - trimmedLine.StartsWith("Public Function ", StringComparison.Ordinal) || - trimmedLine.StartsWith("Private Sub ", StringComparison.Ordinal) || - trimmedLine.StartsWith("Private Function ", StringComparison.Ordinal)) - { - string procName = ExtractProcedureName(codeLine); - if (!string.IsNullOrEmpty(procName)) - { - procedures.Add(procName); - } - } - } - - result.Scripts.Add(new ScriptInfo - { - Name = name, - Type = typeStr, - LineCount = moduleLineCount, - Procedures = procedures - }); - } - finally - { - ComUtilities.Release(ref codeModule); - ComUtilities.Release(ref component); - } - } - - result.Success = true; - return result; - } - catch (COMException comEx) when (comEx.Message.Contains("programmatic access", StringComparison.OrdinalIgnoreCase) || - comEx.ErrorCode == unchecked((int)0x800A03EC)) - { - // Trust was disabled during operation - throw new InvalidOperationException(VbaTrustErrorMessage, comEx); - } - finally - { - ComUtilities.Release(ref vbComponents); - ComUtilities.Release(ref vbaProject); - } - }); - } - - /// <inheritdoc /> - public VbaViewResult View(IExcelBatch batch, string moduleName) - { - var result = new VbaViewResult { FilePath = batch.WorkbookPath, ModuleName = moduleName }; - - var (isValid, validationError) = ValidateVbaFile(batch.WorkbookPath); - if (!isValid) - { - throw new InvalidOperationException(validationError); - } - - if (string.IsNullOrWhiteSpace(moduleName)) - { - throw new ArgumentException("Module name cannot be empty", nameof(moduleName)); - } - - // Check VBA trust BEFORE attempting operation - if (!IsVbaTrustEnabled()) - { - throw new InvalidOperationException(VbaTrustErrorMessage); - } - - return batch.Execute((ctx, ct) => - { - dynamic? vbaProject = null; - dynamic? vbComponents = null; - dynamic? component = null; - dynamic? codeModule = null; - try - { - // PIA gap: VBProject is in Microsoft.Vbe.Interop, not the Excel PIA. - // No .NET 5+ compatible NuGet package exists for VBE types. - vbaProject = ((dynamic)ctx.Book).VBProject; - vbComponents = vbaProject.VBComponents; - - // Find the specified module - bool found = false; - for (int i = 1; i <= vbComponents.Count; i++) - { - component = vbComponents.Item(i); - string name = component.Name; - - if (name.Equals(moduleName, StringComparison.OrdinalIgnoreCase)) - { - found = true; - int type = component.Type; - result.ModuleType = GetVbaModuleTypeName(type); - - codeModule = component.CodeModule; - result.LineCount = codeModule.CountOfLines; - - // Read all code lines - if (result.LineCount > 0) - { - result.Code = codeModule.Lines[1, result.LineCount]; - } - - // Parse procedures - for (int line = 1; line <= result.LineCount; line++) - { - string codeLine = codeModule.Lines[line, 1]; - string trimmedLine = codeLine.TrimStart(); - if (trimmedLine.StartsWith("Sub ", StringComparison.Ordinal) || - trimmedLine.StartsWith("Function ", StringComparison.Ordinal) || - trimmedLine.StartsWith("Public Sub ", StringComparison.Ordinal) || - trimmedLine.StartsWith("Public Function ", StringComparison.Ordinal) || - trimmedLine.StartsWith("Private Sub ", StringComparison.Ordinal) || - trimmedLine.StartsWith("Private Function ", StringComparison.Ordinal)) - { - string procName = ExtractProcedureName(codeLine); - if (!string.IsNullOrEmpty(procName)) - { - result.Procedures.Add(procName); - } - } - } - - break; - } - - ComUtilities.Release(ref component); - component = null; - } - - if (!found) - { - throw new InvalidOperationException($"Module '{moduleName}' not found in workbook"); - } - - result.Success = true; - return result; - } - catch (COMException comEx) when (comEx.Message.Contains("programmatic access", StringComparison.OrdinalIgnoreCase) || - comEx.ErrorCode == unchecked((int)0x800A03EC)) - { - throw new InvalidOperationException(VbaTrustErrorMessage, comEx); - } - finally - { - ComUtilities.Release(ref codeModule); - ComUtilities.Release(ref component); - ComUtilities.Release(ref vbComponents); - ComUtilities.Release(ref vbaProject); - } - }); - } - - /// <inheritdoc /> - public OperationResult Import(IExcelBatch batch, string moduleName, string vbaCode) - { - var (isValid, validationError) = ValidateVbaFile(batch.WorkbookPath); - if (!isValid) - { - throw new InvalidOperationException(validationError); - } - - // Check VBA trust BEFORE attempting operation - if (!IsVbaTrustEnabled()) - { - throw new InvalidOperationException(VbaTrustErrorMessage); - } - - return batch.Execute((ctx, ct) => - { - dynamic? vbaProject = null; - dynamic? vbComponents = null; - dynamic? newModule = null; - dynamic? codeModule = null; - try - { - // PIA gap: VBProject is in Microsoft.Vbe.Interop, not the Excel PIA. - // No .NET 5+ compatible NuGet package exists for VBE types. - vbaProject = ((dynamic)ctx.Book).VBProject; - vbComponents = vbaProject.VBComponents; - - // Check if module already exists - for (int i = 1; i <= vbComponents.Count; i++) - { - dynamic? component = null; - try - { - component = vbComponents.Item(i); - if (component.Name == moduleName) - { - throw new InvalidOperationException($"Module '{moduleName}' already exists. Use script-update to modify it."); - } - } - finally - { - ComUtilities.Release(ref component); - } - } - - // Add new module - newModule = vbComponents.Add(1); // 1 = vbext_ct_StdModule - newModule.Name = moduleName; - - codeModule = newModule.CodeModule; - codeModule.AddFromString(vbaCode); - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - catch (COMException comEx) when (comEx.Message.Contains("programmatic access", StringComparison.OrdinalIgnoreCase) || - comEx.ErrorCode == unchecked((int)0x800A03EC)) - { - throw new InvalidOperationException(VbaTrustErrorMessage, comEx); - } - finally - { - ComUtilities.Release(ref codeModule); - ComUtilities.Release(ref newModule); - ComUtilities.Release(ref vbComponents); - ComUtilities.Release(ref vbaProject); - } - }); - } - - /// <inheritdoc /> - public OperationResult Update(IExcelBatch batch, string moduleName, string vbaCode) - { - var (isValid, validationError) = ValidateVbaFile(batch.WorkbookPath); - if (!isValid) - { - throw new InvalidOperationException(validationError); - } - - // Check VBA trust BEFORE attempting operation - if (!IsVbaTrustEnabled()) - { - throw new InvalidOperationException(VbaTrustErrorMessage); - } - - return batch.Execute((ctx, ct) => - { - dynamic? vbaProject = null; - dynamic? vbComponents = null; - dynamic? targetComponent = null; - dynamic? codeModule = null; - try - { - // PIA gap: VBProject is in Microsoft.Vbe.Interop, not the Excel PIA. - // No .NET 5+ compatible NuGet package exists for VBE types. - vbaProject = ((dynamic)ctx.Book).VBProject; - vbComponents = vbaProject.VBComponents; - - for (int i = 1; i <= vbComponents.Count; i++) - { - dynamic? component = null; - try - { - component = vbComponents.Item(i); - if (component.Name == moduleName) - { - targetComponent = component; - component = null; // Don't release - we're keeping it - break; - } - } - finally - { - if (component != null) - { - ComUtilities.Release(ref component); - } - } - } - - if (targetComponent == null) - { - throw new InvalidOperationException($"Module '{moduleName}' not found. Use script-import to create it."); - } - - codeModule = targetComponent.CodeModule; - int lineCount = codeModule.CountOfLines; - - if (lineCount > 0) - { - codeModule.DeleteLines(1, lineCount); - } - - codeModule.AddFromString(vbaCode); - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - catch (COMException comEx) when (comEx.Message.Contains("programmatic access", StringComparison.OrdinalIgnoreCase) || - comEx.ErrorCode == unchecked((int)0x800A03EC)) - { - throw new InvalidOperationException(VbaTrustErrorMessage, comEx); - } - finally - { - ComUtilities.Release(ref codeModule); - ComUtilities.Release(ref targetComponent); - ComUtilities.Release(ref vbComponents); - ComUtilities.Release(ref vbaProject); - } - }); - } -} - - - diff --git a/src/ExcelMcp.Core/Commands/Vba/VbaCommands.Operations.cs b/src/ExcelMcp.Core/Commands/Vba/VbaCommands.Operations.cs deleted file mode 100644 index f25ffb1b..00000000 --- a/src/ExcelMcp.Core/Commands/Vba/VbaCommands.Operations.cs +++ /dev/null @@ -1,126 +0,0 @@ -using System.Runtime.InteropServices; -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// VBA script operations (Run) -/// </summary> -public partial class VbaCommands -{ - /// <inheritdoc /> - public OperationResult Run(IExcelBatch batch, string procedureName, TimeSpan? timeout, params string[] parameters) - { - var (isValid, validationError) = ValidateVbaFile(batch.WorkbookPath); - if (!isValid) - { - throw new ArgumentException(validationError, nameof(batch)); - } - - // Check VBA trust BEFORE attempting operation - if (!IsVbaTrustEnabled()) - { - throw new InvalidOperationException(VbaTrustErrorMessage); - } - - return batch.Execute((ctx, ct) => - { - try - { - if (parameters.Length == 0) - { - ctx.App.Run(procedureName); - } - else - { - object[] paramObjects = parameters.Cast<object>().ToArray(); - ctx.App.Run(procedureName, paramObjects); - } - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - catch (COMException comEx) when (comEx.Message.Contains("programmatic access", StringComparison.OrdinalIgnoreCase) || - comEx.ErrorCode == unchecked((int)0x800A03EC)) - { - throw new InvalidOperationException(VbaTrustErrorMessage, comEx); - } - }); - } - - /// <inheritdoc /> - public OperationResult Delete(IExcelBatch batch, string moduleName) - { - var (isValid, validationError) = ValidateVbaFile(batch.WorkbookPath); - if (!isValid) - { - throw new InvalidOperationException(validationError); - } - - // Check VBA trust BEFORE attempting operation - if (!IsVbaTrustEnabled()) - { - throw new InvalidOperationException(VbaTrustErrorMessage); - } - - return batch.Execute((ctx, ct) => - { - dynamic? vbaProject = null; - dynamic? vbComponents = null; - dynamic? targetComponent = null; - try - { - // PIA gap: VBProject is in Microsoft.Vbe.Interop, not the Excel PIA. - // No .NET 5+ compatible NuGet package exists for VBE types. - vbaProject = ((dynamic)ctx.Book).VBProject; - vbComponents = vbaProject.VBComponents; - - for (int i = 1; i <= vbComponents.Count; i++) - { - dynamic? component = null; - try - { - component = vbComponents.Item(i); - if (component.Name == moduleName) - { - targetComponent = component; - component = null; // Don't release - we're keeping it - break; - } - } - finally - { - if (component != null) - { - ComUtilities.Release(ref component); - } - } - } - - if (targetComponent == null) - { - throw new InvalidOperationException($"Module '{moduleName}' not found."); - } - - vbComponents.Remove(targetComponent); - - return new OperationResult { Success = true, FilePath = batch.WorkbookPath }; - } - catch (COMException comEx) when (comEx.Message.Contains("programmatic access", StringComparison.OrdinalIgnoreCase) || - comEx.ErrorCode == unchecked((int)0x800A03EC)) - { - throw new InvalidOperationException(VbaTrustErrorMessage, comEx); - } - finally - { - ComUtilities.Release(ref targetComponent); - ComUtilities.Release(ref vbComponents); - ComUtilities.Release(ref vbaProject); - } - }); - } -} - - - diff --git a/src/ExcelMcp.Core/Commands/Vba/VbaCommands.cs b/src/ExcelMcp.Core/Commands/Vba/VbaCommands.cs deleted file mode 100644 index 10d0e86e..00000000 --- a/src/ExcelMcp.Core/Commands/Vba/VbaCommands.cs +++ /dev/null @@ -1,99 +0,0 @@ -using Microsoft.Win32; - -namespace Sbroenne.ExcelMcp.Core.Commands; - -/// <summary> -/// VBA script management commands - Core data layer (no console output) -/// </summary> -public partial class VbaCommands : IVbaCommands -{ - // CA1861: Use static readonly for constant array arguments to avoid repeated allocations - private static readonly string[] RegistryPaths = - [ - @"Software\Microsoft\Office\16.0\Excel\Security", // Office 2019/2021/365 - @"Software\Microsoft\Office\15.0\Excel\Security", // Office 2013 - @"Software\Microsoft\Office\14.0\Excel\Security" // Office 2010 - ]; - - // CA1861: Use static readonly for constant array arguments to avoid repeated allocations - private static readonly char[] ProcedureSeparators = [' ', '(']; - - /// <summary> - /// Check if VBA trust is enabled by reading registry - /// </summary> - private static bool IsVbaTrustEnabled() - { - try - { - // Try different Office versions - foreach (string path in RegistryPaths) - { - try - { - using var key = Registry.CurrentUser.OpenSubKey(path); - var value = key?.GetValue("AccessVBOM"); - if (value != null && (int)value == 1) - { - return true; - } - } - catch (System.Security.SecurityException) - { - // Registry access denied for this path, try next Office version - } - } - - return false; // Assume not enabled if cannot read registry - } - catch (System.Security.SecurityException) - { - // Registry access completely denied, assume VBA trust not enabled - return false; - } - } - - private const string VbaTrustErrorMessage = "VBA trust access is not enabled. Enable 'Trust access to the VBA project object model' in Excel Trust Center settings."; - - /// <summary> - /// Validate that file is macro-enabled (.xlsm) for VBA operations - /// </summary> - private static (bool IsValid, string? ErrorMessage) ValidateVbaFile(string filePath) - { - string extension = Path.GetExtension(filePath).ToLowerInvariant(); - if (extension != ".xlsm") - { - return (false, $"VBA operations require macro-enabled workbooks (.xlsm). Current file has extension: {extension}"); - } - return (true, null); - } - - private static string ExtractProcedureName(string codeLine) - { - var parts = codeLine.Trim().Split(ProcedureSeparators, StringSplitOptions.RemoveEmptyEntries); - for (int i = 0; i < parts.Length; i++) - { - if ((parts[i] is "Sub" or "Function") && i + 1 < parts.Length) - { - return parts[i + 1]; - } - } - return string.Empty; - } - - /// <summary> - /// Converts VBA component type constant to display name - /// </summary> - private static string GetVbaModuleTypeName(int componentType) - { - return componentType switch - { - 1 => "Module", - 2 => "Class", - 3 => "Form", - 100 => "Document", - _ => $"Type{componentType}" - }; - } -} - - diff --git a/src/ExcelMcp.Core/Commands/Window/IWindowCommands.cs b/src/ExcelMcp.Core/Commands/Window/IWindowCommands.cs deleted file mode 100644 index 313750c6..00000000 --- a/src/ExcelMcp.Core/Commands/Window/IWindowCommands.cs +++ /dev/null @@ -1,125 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Attributes; -using Sbroenne.ExcelMcp.Core.Models; - -namespace Sbroenne.ExcelMcp.Core.Commands.Window; - -/// <summary> -/// Result containing Excel window state information. -/// </summary> -public class WindowInfoResult : OperationResult -{ - /// <summary>Whether Excel is currently visible</summary> - public bool IsVisible { get; set; } - - /// <summary>Window state: normal, minimized, or maximized</summary> - public string WindowState { get; set; } = string.Empty; - - /// <summary>Window left position in points</summary> - public double Left { get; set; } - - /// <summary>Window top position in points</summary> - public double Top { get; set; } - - /// <summary>Window width in points</summary> - public double Width { get; set; } - - /// <summary>Window height in points</summary> - public double Height { get; set; } - - /// <summary>Whether this is the foreground window</summary> - public bool IsForeground { get; set; } -} - -/// <summary> -/// Control Excel window visibility, position, state, and status bar. -/// Use to show/hide Excel, bring it to front, reposition, or maximize/minimize. -/// Set status bar text to give users real-time feedback during operations. -/// -/// VISIBILITY: 'show' makes Excel visible AND brings to front. 'hide' hides Excel. -/// Visibility changes are reflected in session metadata (session list shows updated state). -/// -/// WINDOW STATE values: 'normal', 'minimized', 'maximized'. -/// -/// ARRANGE presets: 'left-half', 'right-half', 'top-half', 'bottom-half', 'center', 'full-screen'. -/// -/// STATUS BAR: 'set-status-bar' displays text in Excel's status bar. 'clear-status-bar' restores default. -/// </summary> -[ServiceCategory("window", "Window")] -[McpTool("window", Title = "Window Management", Destructive = false, Category = "settings", - Description = "Control Excel window visibility, position, state, and status bar. show/hide Excel, bring to front, reposition, maximize/minimize, set status bar text. VISIBILITY: 'show' makes Excel visible AND brings to front. 'hide' hides it. WINDOW STATE: normal, minimized, maximized. ARRANGE presets: left-half, right-half, top-half, bottom-half, center, full-screen. STATUS BAR: set-status-bar displays feedback text, clear-status-bar restores default.")] -public interface IWindowCommands -{ - /// <summary> - /// Makes the Excel window visible and brings it to the foreground. - /// </summary> - /// <param name="batch">Excel batch session</param> - [ServiceAction("show")] - OperationResult Show(IExcelBatch batch); - - /// <summary> - /// Hides the Excel window. - /// </summary> - /// <param name="batch">Excel batch session</param> - [ServiceAction("hide")] - OperationResult Hide(IExcelBatch batch); - - /// <summary> - /// Brings the Excel window to the foreground without changing visibility. - /// </summary> - /// <param name="batch">Excel batch session</param> - [ServiceAction("bring-to-front")] - OperationResult BringToFront(IExcelBatch batch); - - /// <summary> - /// Gets current window information (visibility, position, size, state). - /// </summary> - /// <param name="batch">Excel batch session</param> - [ServiceAction("get-info")] - WindowInfoResult GetInfo(IExcelBatch batch); - - /// <summary> - /// Sets the window state (normal, minimized, maximized). - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="windowState">Window state: 'normal', 'minimized', or 'maximized'</param> - [ServiceAction("set-state")] - OperationResult SetState(IExcelBatch batch, [RequiredParameter] string windowState); - - /// <summary> - /// Sets the window position and size in points. - /// All parameters are optional — only provided values are changed. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="left">Window left position in points</param> - /// <param name="top">Window top position in points</param> - /// <param name="width">Window width in points</param> - /// <param name="height">Window height in points</param> - [ServiceAction("set-position")] - OperationResult SetPosition(IExcelBatch batch, double? left = null, double? top = null, double? width = null, double? height = null); - - /// <summary> - /// Arranges the Excel window using a named preset position. - /// Makes Excel visible if hidden. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="preset">Preset name: 'left-half', 'right-half', 'top-half', 'bottom-half', 'center', 'full-screen'</param> - [ServiceAction("arrange")] - OperationResult Arrange(IExcelBatch batch, [RequiredParameter] string preset); - - /// <summary> - /// Sets the Excel status bar text. The text is visible at the bottom of the Excel window. - /// Use to give users real-time feedback about what operation is in progress. - /// </summary> - /// <param name="batch">Excel batch session</param> - /// <param name="text">Status bar text to display (e.g. "Building PivotTable from Sales data...")</param> - [ServiceAction("set-status-bar")] - OperationResult SetStatusBar(IExcelBatch batch, [RequiredParameter] string text); - - /// <summary> - /// Clears the Excel status bar, restoring the default "Ready" text. - /// </summary> - /// <param name="batch">Excel batch session</param> - [ServiceAction("clear-status-bar")] - OperationResult ClearStatusBar(IExcelBatch batch); -} diff --git a/src/ExcelMcp.Core/Commands/Window/WindowCommands.cs b/src/ExcelMcp.Core/Commands/Window/WindowCommands.cs deleted file mode 100644 index c78b7853..00000000 --- a/src/ExcelMcp.Core/Commands/Window/WindowCommands.cs +++ /dev/null @@ -1,352 +0,0 @@ -using System.Runtime.InteropServices; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; -using Excel = Microsoft.Office.Interop.Excel; - -namespace Sbroenne.ExcelMcp.Core.Commands.Window; - -/// <summary> -/// Implementation of window management commands using Excel COM and Win32 P/Invoke. -/// </summary> -public class WindowCommands : IWindowCommands -{ - // Win32 P/Invoke for window management - [DllImport("user32.dll")] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool SetForegroundWindow(IntPtr hWnd); - - [DllImport("user32.dll")] - private static extern IntPtr GetForegroundWindow(); - - // Excel WindowState constants (XlWindowState) - private const int XlMaximized = -4137; // xlMaximized - private const int XlMinimized = -4140; // xlMinimized - private const int XlNormal = -4143; // xlNormal - - /// <summary> - /// Makes the Excel window visible and brings it to the foreground. - /// </summary> - public OperationResult Show(IExcelBatch batch) - { - return batch.Execute((ctx, ct) => - { - ctx.App.Visible = true; - - BringWindowToFront(ctx.App); - - return new OperationResult - { - Success = true, - Action = "show", - Message = "Excel window is now visible and in the foreground" - }; - }); - } - - /// <summary> - /// Hides the Excel window. - /// </summary> - public OperationResult Hide(IExcelBatch batch) - { - return batch.Execute((ctx, ct) => - { - ctx.App.Visible = false; - - return new OperationResult - { - Success = true, - Action = "hide", - Message = "Excel window is now hidden" - }; - }); - } - - /// <summary> - /// Brings the Excel window to the foreground without changing visibility. - /// </summary> - public OperationResult BringToFront(IExcelBatch batch) - { - return batch.Execute((ctx, ct) => - { - if (!(bool)ctx.App.Visible) - { - return new OperationResult - { - Success = true, - Action = "bring-to-front", - Message = "Excel is hidden. Use 'show' first to make it visible before bringing to front." - }; - } - - BringWindowToFront(ctx.App); - - return new OperationResult - { - Success = true, - Action = "bring-to-front", - Message = "Excel window is now in the foreground" - }; - }); - } - - /// <summary> - /// Gets current window information. - /// </summary> - public WindowInfoResult GetInfo(IExcelBatch batch) - { - return batch.Execute((ctx, ct) => - { - bool isVisible = (bool)ctx.App.Visible; - - // Read window properties - these may throw if Excel is minimized or hidden - double left = 0, top = 0, width = 0, height = 0; - string windowState = "normal"; - - if (isVisible) - { - int state = (int)ctx.App.WindowState; - windowState = state switch - { - XlMaximized => "maximized", - XlMinimized => "minimized", - _ => "normal" - }; - - left = Convert.ToDouble(ctx.App.Left); - top = Convert.ToDouble(ctx.App.Top); - width = Convert.ToDouble(ctx.App.Width); - height = Convert.ToDouble(ctx.App.Height); - } - - // Check if this is the foreground window - int hwnd = ctx.App.Hwnd; - IntPtr foreground = GetForegroundWindow(); - bool isForeground = isVisible && foreground == new IntPtr(hwnd); - - return new WindowInfoResult - { - Success = true, - Action = "get-info", - IsVisible = isVisible, - WindowState = windowState, - Left = left, - Top = top, - Width = width, - Height = height, - IsForeground = isForeground, - Message = isVisible - ? $"Excel is visible ({windowState}), position: ({left},{top}), size: {width}x{height}" - : "Excel is hidden" - }; - }); - } - - /// <summary> - /// Sets the window state (normal, minimized, maximized). - /// </summary> - public OperationResult SetState(IExcelBatch batch, string windowState) - { - return batch.Execute((ctx, ct) => - { - int xlState = ParseWindowState(windowState); - - // Ensure Excel is visible before changing state - if (!(bool)ctx.App.Visible) - { - ctx.App.Visible = true; - } - - ctx.App.WindowState = (Excel.XlWindowState)xlState; - - return new OperationResult - { - Success = true, - Action = "set-state", - Message = $"Excel window state set to {windowState}" - }; - }); - } - - /// <summary> - /// Sets the window position and size. - /// </summary> - public OperationResult SetPosition(IExcelBatch batch, double? left = null, double? top = null, double? width = null, double? height = null) - { - return batch.Execute((ctx, ct) => - { - // Ensure Excel is visible - if (!(bool)ctx.App.Visible) - { - ctx.App.Visible = true; - } - - // Set to normal state so position/size can be changed - if ((int)ctx.App.WindowState != XlNormal) - { - ctx.App.WindowState = (Excel.XlWindowState)XlNormal; - } - - if (left.HasValue) ctx.App.Left = left.Value; - if (top.HasValue) ctx.App.Top = top.Value; - if (width.HasValue) ctx.App.Width = width.Value; - if (height.HasValue) ctx.App.Height = height.Value; - - return new OperationResult - { - Success = true, - Action = "set-position", - Message = $"Excel window position updated" - }; - }); - } - - /// <summary> - /// Arranges the Excel window using a named preset position. - /// </summary> - public OperationResult Arrange(IExcelBatch batch, string preset) - { - return batch.Execute((ctx, ct) => - { - // Ensure Excel is visible - if (!(bool)ctx.App.Visible) - { - ctx.App.Visible = true; - } - - // Get screen dimensions via Excel's UsableWidth/UsableHeight - // These are in points and represent the available screen area - double screenWidth = Convert.ToDouble(ctx.App.UsableWidth); - double screenHeight = Convert.ToDouble(ctx.App.UsableHeight); - - // Set to normal state so position/size can be changed - if (preset != "full-screen") - { - if ((int)ctx.App.WindowState != XlNormal) - { - ctx.App.WindowState = (Excel.XlWindowState)XlNormal; - } - } - - switch (preset.ToLowerInvariant()) - { - case "left-half": - ctx.App.Left = 0; - ctx.App.Top = 0; - ctx.App.Width = screenWidth / 2; - ctx.App.Height = screenHeight; - break; - - case "right-half": - ctx.App.Left = screenWidth / 2; - ctx.App.Top = 0; - ctx.App.Width = screenWidth / 2; - ctx.App.Height = screenHeight; - break; - - case "top-half": - ctx.App.Left = 0; - ctx.App.Top = 0; - ctx.App.Width = screenWidth; - ctx.App.Height = screenHeight / 2; - break; - - case "bottom-half": - ctx.App.Left = 0; - ctx.App.Top = screenHeight / 2; - ctx.App.Width = screenWidth; - ctx.App.Height = screenHeight / 2; - break; - - case "center": - double centerWidth = screenWidth * 0.6; - double centerHeight = screenHeight * 0.6; - ctx.App.Left = (screenWidth - centerWidth) / 2; - ctx.App.Top = (screenHeight - centerHeight) / 2; - ctx.App.Width = centerWidth; - ctx.App.Height = centerHeight; - break; - - case "full-screen": - ctx.App.WindowState = (Excel.XlWindowState)XlMaximized; - break; - - default: - throw new ArgumentException( - $"Unknown arrange preset: '{preset}'. " + - "Valid presets: left-half, right-half, top-half, bottom-half, center, full-screen"); - } - - BringWindowToFront(ctx.App); - - return new OperationResult - { - Success = true, - Action = "arrange", - Message = $"Excel window arranged to '{preset}'" - }; - }); - } - - /// <summary> - /// Parses a window state string to the Excel COM constant. - /// </summary> - private static int ParseWindowState(string windowState) - { - return windowState.ToLowerInvariant() switch - { - "normal" => XlNormal, - "minimized" => XlMinimized, - "maximized" => XlMaximized, - _ => throw new ArgumentException( - $"Unknown window state: '{windowState}'. Valid states: normal, minimized, maximized") - }; - } - - /// <summary> - /// Sets the Excel status bar text. - /// </summary> - public OperationResult SetStatusBar(IExcelBatch batch, string text) - { - return batch.Execute((ctx, ct) => - { - ctx.App.StatusBar = text; - - return new OperationResult - { - Success = true, - Action = "set-status-bar", - Message = $"Status bar set to: {text}" - }; - }); - } - - /// <summary> - /// Clears the Excel status bar, restoring the default text. - /// </summary> - public OperationResult ClearStatusBar(IExcelBatch batch) - { - return batch.Execute((ctx, ct) => - { - ctx.App.StatusBar = false; // false restores default "Ready" text - - return new OperationResult - { - Success = true, - Action = "clear-status-bar", - Message = "Status bar restored to default" - }; - }); - } - - /// <summary> - /// Brings the Excel window to the foreground using Win32 SetForegroundWindow. - /// </summary> - private static void BringWindowToFront(dynamic app) - { - int hwnd = app.Hwnd; - if (hwnd != 0) - { - SetForegroundWindow(new IntPtr(hwnd)); - } - } -} diff --git a/src/ExcelMcp.Core/Models/Actions/ActionExtensions.cs b/src/ExcelMcp.Core/Models/Actions/ActionExtensions.cs deleted file mode 100644 index 5095db96..00000000 --- a/src/ExcelMcp.Core/Models/Actions/ActionExtensions.cs +++ /dev/null @@ -1,59 +0,0 @@ -#pragma warning disable CS1591 -namespace Sbroenne.ExcelMcp.Core.Models.Actions; - -/// <summary> -/// Helper extensions to convert enum actions to string format and parse from cli strings. -/// </summary> -public static class ActionExtensions -{ - public static string ToActionString(this FileAction action) => action switch - { - FileAction.List => "list", - FileAction.Open => "open", - FileAction.Close => "close", - FileAction.Create => "create", - FileAction.CloseWorkbook => "close-workbook", - FileAction.Test => "test", - _ => throw new ArgumentException($"Unknown FileAction: {action}") - }; - - // NOTE: PowerQueryAction.ToActionString() is now generated in ServiceRegistry.PowerQuery.ToActionString() - // NOTE: SheetAction.ToActionString() is now generated in ServiceRegistry.Sheet.ToActionString() - // NOTE: SheetStyleAction.ToActionString() is now generated in ServiceRegistry.SheetStyle.ToActionString() - // See Sbroenne.ExcelMcp.Generated namespace - - // NOTE: RangeAction.ToActionString() is now generated in ServiceRegistry.Range.ToActionString() - // NOTE: RangeEditAction.ToActionString() is now generated in ServiceRegistry.RangeEdit.ToActionString() - // NOTE: RangeFormatAction.ToActionString() is now generated in ServiceRegistry.RangeFormat.ToActionString() - // NOTE: RangeLinkAction.ToActionString() is now generated in ServiceRegistry.RangeLink.ToActionString() - - // NamedRangeAction.ToActionString() is now generated in ServiceRegistry.NamedRange.ToActionString() - - // ConditionalFormatAction.ToActionString() is now generated in ServiceRegistry.ConditionalFormat.ToActionString() - - // VbaAction.ToActionString() is now generated in ServiceRegistry.Vba.ToActionString() - - // ConnectionAction.ToActionString() is now generated in ServiceRegistry.Connection.ToActionString() - - // DataModelAction.ToActionString() is now generated in ServiceRegistry.DataModel.ToActionString() - - // DataModelRelAction.ToActionString() is now generated in ServiceRegistry.DataModelRel.ToActionString() - - // TableAction.ToActionString() is now generated in ServiceRegistry.Table.ToActionString() - - // TableColumnAction.ToActionString() is now generated in ServiceRegistry.TableColumn.ToActionString() - - // PivotTableAction.ToActionString() is now generated in ServiceRegistry.PivotTable.ToActionString() - // PivotTableFieldAction.ToActionString() is now generated in ServiceRegistry.PivotTableField.ToActionString() - // PivotTableCalcAction.ToActionString() is now generated in ServiceRegistry.PivotTableCalc.ToActionString() - - // ChartAction.ToActionString() is now generated in ServiceRegistry.Chart.ToActionString() - // ChartConfigAction.ToActionString() is now generated in ServiceRegistry.ChartConfig.ToActionString() - - // SlicerAction.ToActionString() is now generated in ServiceRegistry.Slicer.ToActionString() - - // CalculationModeAction.ToActionString() is now generated in ServiceRegistry.Calculation.ToActionString() -} -#pragma warning restore CS1591 - - diff --git a/src/ExcelMcp.Core/Models/Actions/ToolActions.cs b/src/ExcelMcp.Core/Models/Actions/ToolActions.cs deleted file mode 100644 index 13b6d216..00000000 --- a/src/ExcelMcp.Core/Models/Actions/ToolActions.cs +++ /dev/null @@ -1,85 +0,0 @@ -#pragma warning disable CS1591 -namespace Sbroenne.ExcelMcp.Core.Models.Actions; - -/// <summary> -/// Actions available for file tool -/// </summary> -/// <remarks> -/// IMPORTANT: Keep enum values synchronized with tool switch cases. -/// Enum names are PascalCase (e.g., Create), serialized as kebab-case (e.g., create) via JsonStringEnumMemberName. -/// ActionExtensions.ToActionString() also returns kebab-case for logging/routing. -/// </remarks> -[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter<FileAction>))] -public enum FileAction -{ - [System.Text.Json.Serialization.JsonStringEnumMemberName("list")] - List, - - [System.Text.Json.Serialization.JsonStringEnumMemberName("open")] - Open, - - [System.Text.Json.Serialization.JsonStringEnumMemberName("close")] - Close, - - [System.Text.Json.Serialization.JsonStringEnumMemberName("create")] - Create, - - [System.Text.Json.Serialization.JsonStringEnumMemberName("close-workbook")] - CloseWorkbook, - - [System.Text.Json.Serialization.JsonStringEnumMemberName("test")] - Test -} - -// NOTE: PowerQueryAction is now generated from IPowerQueryCommands interface -// See Sbroenne.ExcelMcp.Generated.PowerQueryAction in ServiceRegistry.PowerQuery.g.cs - -// NOTE: SheetAction and SheetStyleAction are now generated from ISheetCommands and ISheetStyleCommands -// See Sbroenne.ExcelMcp.Generated.SheetAction in ServiceRegistry.Sheet.g.cs -// See Sbroenne.ExcelMcp.Generated.SheetStyleAction in ServiceRegistry.SheetStyle.g.cs - -// NOTE: RangeAction is now generated from IRangeCommands interface -// See Sbroenne.ExcelMcp.Generated.RangeAction in ServiceRegistry.Range.g.cs - -// NOTE: RangeEditAction is now generated from IRangeEditCommands interface -// See Sbroenne.ExcelMcp.Generated.RangeEditAction in ServiceRegistry.RangeEdit.g.cs - -// NOTE: RangeFormatAction is now generated from IRangeFormatCommands interface -// See Sbroenne.ExcelMcp.Generated.RangeFormatAction in ServiceRegistry.RangeFormat.g.cs - -// NOTE: RangeLinkAction is now generated from IRangeLinkCommands interface -// See Sbroenne.ExcelMcp.Generated.RangeLinkAction in ServiceRegistry.RangeLink.g.cs - -// NamedRangeAction is now generated in ServiceRegistry.NamedRange - -// ConditionalFormatAction is now generated in ServiceRegistry.ConditionalFormat - -// VbaAction is now generated from IVbaCommands interface -// See Sbroenne.ExcelMcp.Generated.VbaAction in ServiceRegistry.Vba.g.cs - -// ConnectionAction is now generated in ServiceRegistry.Connection - -// DataModelAction is now generated in ServiceRegistry.DataModel -// DataModelRelAction is now generated in ServiceRegistry.DataModelRel - -// TableAction is now generated in ServiceRegistry.Table -// TableColumnAction is now generated in ServiceRegistry.TableColumn - -// PivotTableAction is now generated in ServiceRegistry.PivotTable -// PivotTableFieldAction is now generated in ServiceRegistry.PivotTableField -// PivotTableCalcAction is now generated in ServiceRegistry.PivotTableCalc - -// ChartAction is now generated from IChartCommands interface -// See Sbroenne.ExcelMcp.Generated.ChartAction in ServiceRegistry.Chart.g.cs - -// ChartConfigAction is now generated from IChartConfigCommands interface -// See Sbroenne.ExcelMcp.Generated.ChartConfigAction in ServiceRegistry.ChartConfig.g.cs - -// SlicerAction is now generated from ISlicerCommands interface -// See Sbroenne.ExcelMcp.Generated.SlicerAction in ServiceRegistry.Slicer.g.cs - -// CalculationModeAction is now generated from ICalculationModeCommands interface -// See Sbroenne.ExcelMcp.Generated.CalculationAction in ServiceRegistry.Calculation.g.cs -#pragma warning restore CS1591 - - diff --git a/src/ExcelMcp.Core/Models/AddToDataModelResult.cs b/src/ExcelMcp.Core/Models/AddToDataModelResult.cs deleted file mode 100644 index 8fa8cce5..00000000 --- a/src/ExcelMcp.Core/Models/AddToDataModelResult.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Sbroenne.ExcelMcp.Core.Models; - -/// <summary> -/// Result of adding an Excel Table to the Data Model. -/// Extends OperationResult with information about bracket-escaped column names. -/// </summary> -public class AddToDataModelResult : OperationResult -{ - /// <summary> - /// Column names that contain literal bracket characters and cannot be referenced in DAX without escaping. - /// Populated when stripBracketColumnNames is false and bracket column names are found. - /// Empty when no bracket column names are present or when stripBracketColumnNames is true. - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string[]? BracketColumnsFound { get; set; } - - /// <summary> - /// Column names that were renamed (bracket characters removed) before adding to the Data Model. - /// Populated only when stripBracketColumnNames is true and bracket column names were found. - /// Each entry is the original column name (with brackets). The renamed version removes the brackets. - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string[]? BracketColumnsRenamed { get; set; } -} diff --git a/src/ExcelMcp.Core/Models/DataModelColumnInfo.cs b/src/ExcelMcp.Core/Models/DataModelColumnInfo.cs deleted file mode 100644 index 185e11ad..00000000 --- a/src/ExcelMcp.Core/Models/DataModelColumnInfo.cs +++ /dev/null @@ -1,25 +0,0 @@ - -namespace Sbroenne.ExcelMcp.Core.Models; - -/// <summary> -/// Information about a Data Model column -/// </summary> -public class DataModelColumnInfo -{ - /// <summary> - /// Column name - /// </summary> - public string Name { get; init; } = ""; - - /// <summary> - /// Column data type - /// </summary> - public string DataType { get; init; } = ""; - - /// <summary> - /// Whether this is a calculated column (has DAX formula) - /// </summary> - public bool IsCalculated { get; init; } -} - - diff --git a/src/ExcelMcp.Core/Models/DataModelInfoResult.cs b/src/ExcelMcp.Core/Models/DataModelInfoResult.cs deleted file mode 100644 index 07903ef8..00000000 --- a/src/ExcelMcp.Core/Models/DataModelInfoResult.cs +++ /dev/null @@ -1,35 +0,0 @@ - -namespace Sbroenne.ExcelMcp.Core.Models; - -/// <summary> -/// Result for getting Data Model summary information -/// </summary> -public class DataModelInfoResult : ResultBase -{ - /// <summary> - /// Number of tables in the model - /// </summary> - public int TableCount { get; set; } - - /// <summary> - /// Number of DAX measures in the model - /// </summary> - public int MeasureCount { get; set; } - - /// <summary> - /// Number of relationships in the model - /// </summary> - public int RelationshipCount { get; set; } - - /// <summary> - /// Total number of rows across all tables - /// </summary> - public int TotalRows { get; set; } - - /// <summary> - /// List of table names - /// </summary> - public List<string> TableNames { get; set; } = []; -} - - diff --git a/src/ExcelMcp.Core/Models/DataModelTableColumnsResult.cs b/src/ExcelMcp.Core/Models/DataModelTableColumnsResult.cs deleted file mode 100644 index 629a9bc0..00000000 --- a/src/ExcelMcp.Core/Models/DataModelTableColumnsResult.cs +++ /dev/null @@ -1,20 +0,0 @@ - -namespace Sbroenne.ExcelMcp.Core.Models; - -/// <summary> -/// Result for listing table columns -/// </summary> -public class DataModelTableColumnsResult : ResultBase -{ - /// <summary> - /// Table name - /// </summary> - public string TableName { get; set; } = ""; - - /// <summary> - /// List of columns in the table - /// </summary> - public List<DataModelColumnInfo> Columns { get; set; } = []; -} - - diff --git a/src/ExcelMcp.Core/Models/DataModelTableViewResult.cs b/src/ExcelMcp.Core/Models/DataModelTableViewResult.cs deleted file mode 100644 index e22f8a50..00000000 --- a/src/ExcelMcp.Core/Models/DataModelTableViewResult.cs +++ /dev/null @@ -1,35 +0,0 @@ - -namespace Sbroenne.ExcelMcp.Core.Models; - -/// <summary> -/// Result for viewing table details -/// </summary> -public class DataModelTableViewResult : ResultBase -{ - /// <summary> - /// Table name - /// </summary> - public string TableName { get; set; } = ""; - - /// <summary> - /// Source query or connection name - /// </summary> - public string SourceName { get; set; } = ""; - - /// <summary> - /// Number of rows in the table - /// </summary> - public int RecordCount { get; set; } - - /// <summary> - /// List of columns in the table - /// </summary> - public List<DataModelColumnInfo> Columns { get; set; } = []; - - /// <summary> - /// Number of measures defined in this table - /// </summary> - public int MeasureCount { get; set; } -} - - diff --git a/src/ExcelMcp.Core/Models/DateGroupingInterval.cs b/src/ExcelMcp.Core/Models/DateGroupingInterval.cs deleted file mode 100644 index 1255e171..00000000 --- a/src/ExcelMcp.Core/Models/DateGroupingInterval.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace Sbroenne.ExcelMcp.Core.Models; - -/// <summary> -/// Represents the interval for grouping date/time fields in PivotTables. -/// </summary> -/// <remarks> -/// These intervals correspond to Excel's date grouping options for PivotTable fields. -/// Reference: https://learn.microsoft.com/en-us/office/vba/api/excel.xlcalculation -/// </remarks> -public enum DateGroupingInterval -{ - /// <summary> - /// Group by days. - /// </summary> - Days = 1, - - /// <summary> - /// Group by months. - /// </summary> - Months = 2, - - /// <summary> - /// Group by quarters (Q1, Q2, Q3, Q4). - /// </summary> - Quarters = 3, - - /// <summary> - /// Group by years. - /// </summary> - Years = 4 -} - - diff --git a/src/ExcelMcp.Core/Models/PivotTableTypes.cs b/src/ExcelMcp.Core/Models/PivotTableTypes.cs deleted file mode 100644 index 934e8bfb..00000000 --- a/src/ExcelMcp.Core/Models/PivotTableTypes.cs +++ /dev/null @@ -1,961 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Sbroenne.ExcelMcp.Core.Models; - -/// <summary> -/// PivotTable field areas -/// </summary> -public enum PivotFieldArea -{ - /// <summary> - /// Field is not displayed - /// </summary> - Hidden = 0, - - /// <summary> - /// Field is in the Row area - /// </summary> - Row = 1, - - /// <summary> - /// Field is in the Column area - /// </summary> - Column = 2, - - /// <summary> - /// Field is in the Filter area (Page field) - /// </summary> - Filter = 3, - - /// <summary> - /// Field is in the Values area (Data field) - /// </summary> - Value = 4 -} - -/// <summary> -/// Aggregation functions for PivotTable value fields -/// </summary> -public enum AggregationFunction -{ - /// <summary> - /// Sum of values - /// </summary> - Sum, - - /// <summary> - /// Count of all items - /// </summary> - Count, - - /// <summary> - /// Average of values - /// </summary> - Average, - - /// <summary> - /// Maximum value - /// </summary> - Max, - - /// <summary> - /// Minimum value - /// </summary> - Min, - - /// <summary> - /// Product of values - /// </summary> - Product, - - /// <summary> - /// Count of numeric values - /// </summary> - CountNumbers, - - /// <summary> - /// Standard deviation (sample) - /// </summary> - StdDev, - - /// <summary> - /// Standard deviation (population) - /// </summary> - StdDevP, - - /// <summary> - /// Variance (sample) - /// </summary> - Var, - - /// <summary> - /// Variance (population) - /// </summary> - VarP -} - -/// <summary> -/// Sort direction -/// </summary> -public enum SortDirection -{ - /// <summary> - /// Ascending order - /// </summary> - Ascending, - - /// <summary> - /// Descending order - /// </summary> - Descending -} - -/// <summary> -/// Excel COM constants for PivotTable field orientation -/// </summary> -public static class XlPivotFieldOrientation -{ - /// <summary> - /// Field not displayed - /// </summary> - public const int xlHidden = 0; - - /// <summary> - /// Row area - /// </summary> - public const int xlRowField = 1; - - /// <summary> - /// Column area - /// </summary> - public const int xlColumnField = 2; - - /// <summary> - /// Filter area (Page field) - /// </summary> - public const int xlPageField = 3; - - /// <summary> - /// Values area (Data field) - /// </summary> - public const int xlDataField = 4; -} - -/// <summary> -/// Excel COM constants for consolidation functions -/// </summary> -public static class XlConsolidationFunction -{ - /// <summary> - /// Sum function - /// </summary> - public const int xlSum = -4157; - - /// <summary> - /// Count function - /// </summary> - public const int xlCount = -4112; - - /// <summary> - /// Average function - /// </summary> - public const int xlAverage = -4106; - - /// <summary> - /// Max function - /// </summary> - public const int xlMax = -4136; - - /// <summary> - /// Min function - /// </summary> - public const int xlMin = -4139; - - /// <summary> - /// Product function - /// </summary> - public const int xlProduct = -4149; - - /// <summary> - /// Count numbers function - /// </summary> - public const int xlCountNums = -4113; - - /// <summary> - /// Standard deviation function - /// </summary> - public const int xlStdDev = -4155; - - /// <summary> - /// Standard deviation population function - /// </summary> - public const int xlStdDevP = -4156; - - /// <summary> - /// Variance function - /// </summary> - public const int xlVar = -4164; - - /// <summary> - /// Variance population function - /// </summary> - public const int xlVarP = -4165; -} - -/// <summary> -/// Excel COM constants for CubeField.CubeFieldType property. -/// Used to distinguish between different types of OLAP CubeFields. -/// </summary> -public static class XlCubeFieldType -{ - /// <summary> - /// Hierarchy field (table column/dimension) - /// </summary> - public const int xlHierarchy = 1; - - /// <summary> - /// Measure field (DAX measure or implicit measure) - /// </summary> - public const int xlMeasure = 2; - - /// <summary> - /// Set field - /// </summary> - public const int xlSet = 3; -} - -/// <summary> -/// Excel COM constants for sort order -/// </summary> -public static class XlSortOrder -{ - /// <summary> - /// Sort ascending - /// </summary> - public const int xlAscending = 1; - - /// <summary> - /// Sort descending - /// </summary> - public const int xlDescending = 2; -} - -/// <summary> -/// Excel PivotField data type constants -/// </summary> -public static class XlPivotFieldDataType -{ - /// <summary> - /// Date field type - /// </summary> - public const int xlDate = 2; - - /// <summary> - /// Number field type - /// </summary> - public const int xlNumber = -4145; - - /// <summary> - /// Text field type - /// </summary> - public const int xlText = -4158; -} - -/// <summary> -/// Excel time unit constants for date grouping -/// </summary> -public static class XlTimeUnit -{ - /// <summary> - /// Days grouping - /// </summary> - public const int xlDays = 4; - - /// <summary> - /// Months grouping - /// </summary> - public const int xlMonths = 5; - - /// <summary> - /// Quarters grouping - /// </summary> - public const int xlQuarters = 6; - - /// <summary> - /// Years grouping - /// </summary> - public const int xlYears = 7; -} - -/// <summary> -/// Result for PivotTable creation operations -/// </summary> -public class PivotTableCreateResult : ResultBase -{ - /// <summary> - /// Name of the created PivotTable - /// </summary> - public string PivotTableName { get; set; } = string.Empty; - - /// <summary> - /// Sheet containing the PivotTable - /// </summary> - public string SheetName { get; set; } = string.Empty; - - /// <summary> - /// Range occupied by the PivotTable - /// </summary> - public string Range { get; set; } = string.Empty; - - /// <summary> - /// Source data reference - /// </summary> - public string SourceData { get; set; } = string.Empty; - - /// <summary> - /// Number of rows in source data (excluding headers) - /// </summary> - public int SourceRowCount { get; set; } - - /// <summary> - /// All available fields from source data that can be added to the PivotTable - /// </summary> - public List<string> AvailableFields { get; set; } = []; -} - -/// <summary> -/// Information about a PivotTable -/// </summary> -public class PivotTableInfo -{ - /// <summary> - /// Name of the PivotTable - /// </summary> - public string Name { get; set; } = string.Empty; - - /// <summary> - /// Sheet containing the PivotTable - /// </summary> - public string SheetName { get; set; } = string.Empty; - - /// <summary> - /// Range occupied by the PivotTable - /// </summary> - public string Range { get; set; } = string.Empty; - - /// <summary> - /// Source data reference - /// </summary> - public string SourceData { get; set; } = string.Empty; - - /// <summary> - /// Number of row fields - /// </summary> - public int RowFieldCount { get; set; } - - /// <summary> - /// Number of column fields - /// </summary> - public int ColumnFieldCount { get; set; } - - /// <summary> - /// Number of value fields - /// </summary> - public int ValueFieldCount { get; set; } - - /// <summary> - /// Number of filter fields - /// </summary> - public int FilterFieldCount { get; set; } - - /// <summary> - /// Last refresh timestamp - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public DateTime? LastRefresh { get; set; } -} - -/// <summary> -/// Result for listing PivotTables -/// </summary> -public class PivotTableListResult : ResultBase -{ - /// <summary> - /// List of PivotTables in the workbook - /// </summary> - public List<PivotTableInfo> PivotTables { get; set; } = []; -} - -/// <summary> -/// Result for getting PivotTable information -/// </summary> -public class PivotTableInfoResult : ResultBase -{ - /// <summary> - /// Detailed information about the PivotTable - /// </summary> - public PivotTableInfo PivotTable { get; set; } = new(); - - /// <summary> - /// List of all fields in the PivotTable - /// </summary> - public List<PivotFieldInfo> Fields { get; set; } = []; -} - -/// <summary> -/// Information about a PivotTable field -/// </summary> -public class PivotFieldInfo -{ - /// <summary> - /// Name of the field - /// </summary> - public string Name { get; set; } = string.Empty; - - /// <summary> - /// Custom name/caption - /// </summary> - public string CustomName { get; set; } = string.Empty; - - /// <summary> - /// Area where the field is placed - /// </summary> - public PivotFieldArea Area { get; set; } - - /// <summary> - /// Position within the area (1-based) - /// </summary> - public int Position { get; set; } - - /// <summary> - /// Aggregation function (for value fields) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public AggregationFunction? Function { get; set; } - - /// <summary> - /// Data type of the field - /// </summary> - public string DataType { get; set; } = string.Empty; - - /// <summary> - /// Formula for calculated fields (e.g., "=Revenue-Cost") - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Formula { get; set; } -} - -/// <summary> -/// Result for field listing operations -/// </summary> -public class PivotFieldListResult : ResultBase -{ - /// <summary> - /// List of all fields in the PivotTable - /// </summary> - public List<PivotFieldInfo> Fields { get; set; } = []; -} - -/// <summary> -/// Result for field operations -/// </summary> -public class PivotFieldResult : ResultBase -{ - /// <summary> - /// Name of the field - /// </summary> - public string FieldName { get; set; } = string.Empty; - - /// <summary> - /// Custom name/caption - /// </summary> - public string CustomName { get; set; } = string.Empty; - - /// <summary> - /// Area where the field is placed - /// </summary> - public PivotFieldArea Area { get; set; } - - /// <summary> - /// Position within the area (1-based) - /// </summary> - public int Position { get; set; } - - /// <summary> - /// Aggregation function (for value fields) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public AggregationFunction? Function { get; set; } - - /// <summary> - /// Number format - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? NumberFormat { get; set; } - - /// <summary> - /// Available values for filtering - /// </summary> - public List<string> AvailableValues { get; set; } = []; - - /// <summary> - /// Sample value for verification - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public object? SampleValue { get; set; } - - /// <summary> - /// Data type of the field - /// </summary> - public string DataType { get; set; } = string.Empty; - - /// <summary> - /// Formula for calculated fields (e.g., "=Revenue-Cost") - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Formula { get; set; } - - /// <summary> - /// Workflow hint describing what happened and suggested next steps - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? WorkflowHint { get; set; } -} - -/// <summary> -/// Result for PivotTable refresh operations -/// </summary> -public class PivotTableRefreshResult : ResultBase -{ - /// <summary> - /// Name of the PivotTable - /// </summary> - public string PivotTableName { get; set; } = string.Empty; - - /// <summary> - /// Refresh timestamp - /// </summary> - public DateTime RefreshTime { get; set; } - - /// <summary> - /// Number of records in source data - /// </summary> - public int SourceRecordCount { get; set; } - - /// <summary> - /// Previous record count (before refresh) - /// </summary> - public int PreviousRecordCount { get; set; } - - /// <summary> - /// Whether structure changed - /// </summary> - public bool StructureChanged { get; set; } - - /// <summary> - /// Fields added to source - /// </summary> - public List<string> NewFields { get; set; } = []; - - /// <summary> - /// Fields removed from source - /// </summary> - public List<string> RemovedFields { get; set; } = []; -} - -/// <summary> -/// Result for getting PivotTable data -/// </summary> -public class PivotTableDataResult : ResultBase -{ - /// <summary> - /// Name of the PivotTable - /// </summary> - public string PivotTableName { get; set; } = string.Empty; - - /// <summary> - /// 2D array of PivotTable data - /// </summary> - public List<List<object?>> Values { get; set; } = []; - - /// <summary> - /// Column headers - /// </summary> - public List<string> ColumnHeaders { get; set; } = []; - - /// <summary> - /// Row headers - /// </summary> - public List<string> RowHeaders { get; set; } = []; - - /// <summary> - /// Number of data rows - /// </summary> - public int DataRowCount { get; set; } - - /// <summary> - /// Number of data columns - /// </summary> - public int DataColumnCount { get; set; } - - /// <summary> - /// Grand totals - /// </summary> - public Dictionary<string, object?> GrandTotals { get; set; } = []; -} - -/// <summary> -/// Result for field filter operations -/// </summary> -public class PivotFieldFilterResult : ResultBase -{ - /// <summary> - /// Name of the field - /// </summary> - public string FieldName { get; set; } = string.Empty; - - /// <summary> - /// Selected items - /// </summary> - public List<string> SelectedItems { get; set; } = []; - - /// <summary> - /// Available items - /// </summary> - public List<string> AvailableItems { get; set; } = []; - - /// <summary> - /// Number of visible rows after filter - /// </summary> - public int VisibleRowCount { get; set; } - - /// <summary> - /// Total rows before filter - /// </summary> - public int TotalRowCount { get; set; } - - /// <summary> - /// Whether all items are shown - /// </summary> - public bool ShowAll { get; set; } -} - -/// <summary> -/// Information about a calculated field in a regular PivotTable -/// </summary> -public class CalculatedFieldInfo -{ - /// <summary> - /// Name of the calculated field - /// </summary> - public string Name { get; set; } = string.Empty; - - /// <summary> - /// Formula for the calculated field (e.g., "=Revenue-Cost") - /// </summary> - public string Formula { get; set; } = string.Empty; - - /// <summary> - /// Source name of the field - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? SourceName { get; set; } -} - -/// <summary> -/// Result for listing calculated fields -/// </summary> -public class CalculatedFieldListResult : ResultBase -{ - /// <summary> - /// List of calculated fields in the PivotTable - /// </summary> - public List<CalculatedFieldInfo> CalculatedFields { get; set; } = []; -} - -/// <summary> -/// Excel COM constants for calculated member types -/// </summary> -public static class XlCalculatedMemberType -{ - /// <summary> - /// Calculated member (custom MDX formula member) - /// </summary> - public const int xlCalculatedMember = 0; - - /// <summary> - /// Calculated set (named set of members) - /// </summary> - public const int xlCalculatedSet = 1; - - /// <summary> - /// Calculated measure (DAX-like measure for Data Model) - /// </summary> - public const int xlCalculatedMeasure = 2; -} - -/// <summary> -/// Type of calculated member -/// </summary> -public enum CalculatedMemberType -{ - /// <summary> - /// Calculated member (custom MDX formula member) - /// </summary> - Member = 0, - - /// <summary> - /// Calculated set (named set of members) - /// </summary> - Set = 1, - - /// <summary> - /// Calculated measure (DAX-like measure for Data Model) - /// </summary> - Measure = 2 -} - -/// <summary> -/// Information about a calculated member in a PivotTable -/// </summary> -public class CalculatedMemberInfo -{ - /// <summary> - /// Name of the calculated member - /// </summary> - public string Name { get; set; } = string.Empty; - - /// <summary> - /// MDX or DAX formula - /// </summary> - public string Formula { get; set; } = string.Empty; - - /// <summary> - /// Type of calculated member (Member, Set, or Measure) - /// </summary> - public CalculatedMemberType Type { get; set; } - - /// <summary> - /// Solve order for calculation precedence - /// </summary> - public int SolveOrder { get; set; } - - /// <summary> - /// Whether the calculated member is valid - /// </summary> - public bool IsValid { get; set; } - - /// <summary> - /// Display folder path (for measures) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? DisplayFolder { get; set; } - - /// <summary> - /// Number format code - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? NumberFormat { get; set; } -} - -/// <summary> -/// Result for listing calculated members -/// </summary> -public class CalculatedMemberListResult : ResultBase -{ - /// <summary> - /// List of calculated members in the PivotTable - /// </summary> - public List<CalculatedMemberInfo> CalculatedMembers { get; set; } = []; -} - -/// <summary> -/// Result for calculated member operations -/// </summary> -public class CalculatedMemberResult : ResultBase -{ - /// <summary> - /// Name of the calculated member - /// </summary> - public string Name { get; set; } = string.Empty; - - /// <summary> - /// MDX or DAX formula - /// </summary> - public string Formula { get; set; } = string.Empty; - - /// <summary> - /// Type of calculated member - /// </summary> - public CalculatedMemberType Type { get; set; } - - /// <summary> - /// Solve order for calculation precedence - /// </summary> - public int SolveOrder { get; set; } - - /// <summary> - /// Whether the calculated member is valid - /// </summary> - public bool IsValid { get; set; } - - /// <summary> - /// Display folder path (for measures) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? DisplayFolder { get; set; } - - /// <summary> - /// Number format code - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? NumberFormat { get; set; } - - /// <summary> - /// Workflow hint describing what happened and suggested next steps - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? WorkflowHint { get; set; } -} - -// ==================== SLICER TYPES ==================== - -/// <summary> -/// Information about a slicer connected to a PivotTable -/// </summary> -public class SlicerInfo -{ - /// <summary> - /// Name of the slicer - /// </summary> - public string Name { get; set; } = string.Empty; - - /// <summary> - /// Caption displayed on the slicer - /// </summary> - public string Caption { get; set; } = string.Empty; - - /// <summary> - /// Name of the source field for the slicer - /// </summary> - public string FieldName { get; set; } = string.Empty; - - /// <summary> - /// Name of the worksheet containing the slicer - /// </summary> - public string SheetName { get; set; } = string.Empty; - - /// <summary> - /// Top-left cell position of the slicer - /// </summary> - public string Position { get; set; } = string.Empty; - - /// <summary> - /// Currently selected items in the slicer - /// </summary> - public List<string> SelectedItems { get; set; } = []; - - /// <summary> - /// All available items in the slicer - /// </summary> - public List<string> AvailableItems { get; set; } = []; - - /// <summary> - /// Number of columns in the slicer layout - /// </summary> - public int ColumnCount { get; set; } - - /// <summary> - /// Names of PivotTables connected to this slicer (for PivotTable slicers) - /// </summary> - public List<string> ConnectedPivotTables { get; set; } = []; - - /// <summary> - /// Name of the Table connected to this slicer (for Table slicers) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? ConnectedTable { get; set; } - - /// <summary> - /// Type of source: "PivotTable" or "Table" - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? SourceType { get; set; } -} - -/// <summary> -/// Result for listing slicers -/// </summary> -public class SlicerListResult : ResultBase -{ - /// <summary> - /// List of slicers in the workbook (optionally filtered by PivotTable) - /// </summary> - public List<SlicerInfo> Slicers { get; set; } = []; -} - -/// <summary> -/// Result for slicer operations (create, delete, set selection) -/// </summary> -public class SlicerResult : ResultBase -{ - /// <summary> - /// Name of the slicer - /// </summary> - public string Name { get; set; } = string.Empty; - - /// <summary> - /// Caption displayed on the slicer - /// </summary> - public string Caption { get; set; } = string.Empty; - - /// <summary> - /// Name of the source field for the slicer - /// </summary> - public string FieldName { get; set; } = string.Empty; - - /// <summary> - /// Name of the worksheet containing the slicer - /// </summary> - public string SheetName { get; set; } = string.Empty; - - /// <summary> - /// Top-left cell position of the slicer - /// </summary> - public string Position { get; set; } = string.Empty; - - /// <summary> - /// Currently selected items in the slicer - /// </summary> - public List<string> SelectedItems { get; set; } = []; - - /// <summary> - /// All available items in the slicer - /// </summary> - public List<string> AvailableItems { get; set; } = []; - - /// <summary> - /// Names of PivotTables connected to this slicer (for PivotTable slicers) - /// </summary> - public List<string> ConnectedPivotTables { get; set; } = []; - - /// <summary> - /// Name of the Table connected to this slicer (for Table slicers) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? ConnectedTable { get; set; } - - /// <summary> - /// Type of source: "PivotTable" or "Table" - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? SourceType { get; set; } - - /// <summary> - /// Workflow hint describing what happened and suggested next steps - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? WorkflowHint { get; set; } -} - - diff --git a/src/ExcelMcp.Core/Models/ResultTypes.cs b/src/ExcelMcp.Core/Models/ResultTypes.cs deleted file mode 100644 index 251eed9b..00000000 --- a/src/ExcelMcp.Core/Models/ResultTypes.cs +++ /dev/null @@ -1,2470 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Sbroenne.ExcelMcp.Core.Models; - -/// <summary> -/// Base result type for all Core operations. -/// NOTE: Core commands should NOT set SuggestedNextActions (workflow guidance is MCP/CLI layer responsibility). -/// Exceptions propagate naturally — batch.Execute() re-throws them via TaskCompletionSource. -/// The MCP/CLI layer catches exceptions and converts them to error responses. -/// </summary> -public abstract class ResultBase -{ - /// <summary> - /// Indicates whether the operation was successful - /// </summary> - public bool Success { get; set; } - - /// <summary> - /// Error message if operation failed - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? ErrorMessage { get; set; } - - /// <summary> - /// File path of the Excel file - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? FilePath { get; set; } -} - -/// <summary> -/// Result for operations that don't return data (create, delete, etc.) -/// </summary> -public class OperationResult : ResultBase -{ - /// <summary> - /// Action that was performed - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Action { get; set; } - - /// <summary> - /// Success message describing what was done - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Message { get; set; } -} - -/// <summary> -/// Result for rename operations across Core features -/// </summary> -public class RenameResult : ResultBase -{ - /// <summary> - /// Type of object being renamed (power-query, data-model-table) - /// </summary> - public string ObjectType { get; set; } = string.Empty; - - /// <summary> - /// Original name provided by the caller - /// </summary> - public string OldName { get; set; } = string.Empty; - - /// <summary> - /// Desired new name provided by the caller - /// </summary> - public string NewName { get; set; } = string.Empty; - - /// <summary> - /// Trimmed old name used for comparisons - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string NormalizedOldName { get; set; } = string.Empty; - - /// <summary> - /// Trimmed new name used for comparisons - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string NormalizedNewName { get; set; } = string.Empty; -} - -/// <summary> -/// Result for listing worksheets -/// </summary> -public class WorksheetListResult : ResultBase -{ - /// <summary> - /// List of worksheets in the workbook - /// </summary> - public List<WorksheetInfo> Worksheets { get; set; } = []; -} - -/// <summary> -/// Information about a worksheet -/// </summary> -public class WorksheetInfo -{ - /// <summary> - /// Name of the worksheet - /// </summary> - public string Name { get; set; } = string.Empty; - - /// <summary> - /// Index of the worksheet (1-based) - /// </summary> - public int Index { get; set; } - - /// <summary> - /// Whether the worksheet is visible - /// </summary> - public bool Visible { get; set; } -} - -/// <summary> -/// Sheet visibility levels (maps to Excel XlSheetVisibility) -/// </summary> -public enum SheetVisibility -{ - /// <summary> - /// Sheet is visible (xlSheetVisible = -1) - /// </summary> - Visible = -1, - - /// <summary> - /// Sheet is hidden but user can unhide via Excel UI (xlSheetHidden = 0) - /// </summary> - Hidden = 0, - - /// <summary> - /// Sheet is very hidden, requires code to unhide (xlSheetVeryHidden = 2) - /// </summary> - VeryHidden = 2 -} - -/// <summary> -/// Result for getting worksheet tab color -/// </summary> -public class TabColorResult : ResultBase -{ - /// <summary> - /// Whether the sheet has a tab color set - /// </summary> - public bool HasColor { get; set; } - - /// <summary> - /// Red component (0-255), null if no color - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public int? Red { get; set; } - - /// <summary> - /// Green component (0-255), null if no color - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public int? Green { get; set; } - - /// <summary> - /// Blue component (0-255), null if no color - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public int? Blue { get; set; } - - /// <summary> - /// Hex color string (#RRGGBB), null if no color - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? HexColor { get; set; } -} - -/// <summary> -/// Result for getting worksheet visibility -/// </summary> -public class SheetVisibilityResult : ResultBase -{ - /// <summary> - /// Visibility level - /// </summary> - public SheetVisibility Visibility { get; set; } - - /// <summary> - /// Visibility name (Visible, Hidden, VeryHidden) - /// </summary> - public string VisibilityName { get; set; } = string.Empty; -} - -/// <summary> -/// Result for reading worksheet data -/// </summary> -public class WorksheetDataResult : ResultBase -{ - /// <summary> - /// Name of the worksheet - /// </summary> - public string SheetName { get; set; } = string.Empty; - - /// <summary> - /// Range that was read - /// </summary> - public string Range { get; set; } = string.Empty; - - /// <summary> - /// Data rows and columns - /// </summary> - public List<List<object?>> Data { get; set; } = []; - - /// <summary> - /// Column headers - /// </summary> - public List<string> Headers { get; set; } = []; - - /// <summary> - /// Number of rows - /// </summary> - public int RowCount { get; set; } - - /// <summary> - /// Number of columns - /// </summary> - public int ColumnCount { get; set; } -} - -/// <summary> -/// Result for listing Power Queries -/// </summary> -public class PowerQueryListResult : ResultBase -{ - /// <summary> - /// List of Power Queries in the workbook - /// </summary> - public List<PowerQueryInfo> Queries { get; set; } = []; -} - -/// <summary> -/// Information about a Power Query -/// </summary> -public class PowerQueryInfo -{ - /// <summary> - /// Name of the Power Query - /// </summary> - public string Name { get; set; } = string.Empty; - - /// <summary> - /// Full M code formula - /// </summary> - public string Formula { get; set; } = string.Empty; - - /// <summary> - /// Preview of the formula (first 80 characters) - /// </summary> - public string FormulaPreview { get; set; } = string.Empty; - - /// <summary> - /// Whether the query is connection-only - /// </summary> - public bool IsConnectionOnly { get; set; } -} - -/// <summary> -/// Result for viewing Power Query code -/// </summary> -public class PowerQueryViewResult : ResultBase -{ - /// <summary> - /// Name of the Power Query - /// </summary> - public string QueryName { get; set; } = string.Empty; - - /// <summary> - /// Full M code - /// </summary> - public string MCode { get; set; } = string.Empty; - - /// <summary> - /// Number of characters in the M code - /// </summary> - public int CharacterCount { get; set; } - - /// <summary> - /// Whether the query is connection-only - /// </summary> - public bool IsConnectionOnly { get; set; } -} - -/// <summary> -/// Power Query load configuration modes -/// </summary> -[JsonConverter(typeof(JsonStringEnumConverter<PowerQueryLoadMode>))] -public enum PowerQueryLoadMode -{ - /// <summary> - /// Connection only - no data loaded to worksheet or data model - /// </summary> - [JsonStringEnumMemberName("connection-only")] - ConnectionOnly, - - /// <summary> - /// Load to table in worksheet - /// </summary> - [JsonStringEnumMemberName("load-to-table")] - LoadToTable, - - /// <summary> - /// Load to Data Model (PowerPivot) - /// </summary> - [JsonStringEnumMemberName("load-to-data-model")] - LoadToDataModel, - - /// <summary> - /// Load to both table and data model - /// </summary> - [JsonStringEnumMemberName("load-to-both")] - LoadToBoth -} - -/// <summary> -/// Result for Power Query load configuration -/// </summary> -public class PowerQueryLoadConfigResult : ResultBase -{ - /// <summary> - /// Name of the query - /// </summary> - public string QueryName { get; set; } = string.Empty; - - /// <summary> - /// Current load mode - /// </summary> - public PowerQueryLoadMode LoadMode { get; set; } - - /// <summary> - /// Target worksheet name (if LoadToTable or LoadToBoth) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? TargetSheet { get; set; } - - /// <summary> - /// Whether the query has an active connection - /// </summary> - public bool HasConnection { get; set; } - - /// <summary> - /// Whether the query is loaded to data model - /// </summary> - public bool IsLoadedToDataModel { get; set; } -} - -/// <summary> -/// Result for Power Query load-to-data-model operations with verification -/// Extends OperationResult to provide detailed verification of atomic operation -/// </summary> -public class PowerQueryLoadToDataModelResult : OperationResult -{ - /// <summary> - /// Name of the query - /// </summary> - public string QueryName { get; set; } = string.Empty; - - /// <summary> - /// Whether the load configuration was successfully applied - /// </summary> - public bool ConfigurationApplied { get; set; } - - /// <summary> - /// Whether data was actually loaded to the Data Model - /// </summary> - public bool DataLoadedToModel { get; set; } - - /// <summary> - /// Number of rows loaded to the Data Model (0 if not loaded) - /// </summary> - public int RowsLoaded { get; set; } - - /// <summary> - /// Total number of tables in the Data Model after operation - /// </summary> - public int TablesInDataModel { get; set; } - - /// <summary> - /// Overall workflow status: "Complete" | "Failed" | "Partial" - /// </summary> - public string WorkflowStatus { get; set; } = "Failed"; -} - -/// <summary> -/// Result for Power Query load-to-table operations with verification -/// Extends OperationResult to provide detailed verification of atomic operation -/// </summary> -public class PowerQueryLoadToTableResult : OperationResult -{ - /// <summary> - /// Name of the query - /// </summary> - public string QueryName { get; set; } = string.Empty; - - /// <summary> - /// Name of the target worksheet - /// </summary> - public string SheetName { get; set; } = string.Empty; - - /// <summary> - /// Whether the load configuration was successfully applied - /// </summary> - public bool ConfigurationApplied { get; set; } - - /// <summary> - /// Whether data was actually loaded to the worksheet table - /// </summary> - public bool DataLoadedToTable { get; set; } - - /// <summary> - /// Number of rows loaded to the worksheet table (0 if not loaded) - /// </summary> - public int RowsLoaded { get; set; } - - /// <summary> - /// Overall workflow status: "Complete" | "Failed" | "Partial" - /// </summary> - public string WorkflowStatus { get; set; } = "Failed"; -} - -/// <summary> -/// Result for Power Query load-to-both operations with verification -/// Extends OperationResult to provide detailed verification of atomic operation -/// </summary> -public class PowerQueryLoadToBothResult : OperationResult -{ - /// <summary> - /// Name of the query - /// </summary> - public string QueryName { get; set; } = string.Empty; - - /// <summary> - /// Name of the target worksheet - /// </summary> - public string SheetName { get; set; } = string.Empty; - - /// <summary> - /// Whether the load configuration was successfully applied - /// </summary> - public bool ConfigurationApplied { get; set; } - - /// <summary> - /// Whether data was actually loaded to the worksheet table - /// </summary> - public bool DataLoadedToTable { get; set; } - - /// <summary> - /// Whether data was actually loaded to the Data Model - /// </summary> - public bool DataLoadedToModel { get; set; } - - /// <summary> - /// Number of rows loaded to the worksheet table (0 if not loaded) - /// </summary> - public int RowsLoadedToTable { get; set; } - - /// <summary> - /// Number of rows loaded to the Data Model (0 if not loaded) - /// </summary> - public int RowsLoadedToModel { get; set; } - - /// <summary> - /// Total number of tables in the Data Model after operation - /// </summary> - public int TablesInDataModel { get; set; } - - /// <summary> - /// Overall workflow status: "Complete" | "Failed" | "Partial" - /// </summary> - public string WorkflowStatus { get; set; } = "Failed"; -} - -/// <summary> -/// Result for Power Query create operations -/// Atomic operation: Import M code + Load data to destination in ONE call -/// </summary> -public class PowerQueryCreateResult : OperationResult -{ - /// <summary> - /// Name of the created query - /// </summary> - public string QueryName { get; set; } = string.Empty; - - /// <summary> - /// Load destination applied - /// </summary> - public PowerQueryLoadMode LoadDestination { get; set; } - - /// <summary> - /// Target worksheet name (if LoadToTable or LoadToBoth) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? WorksheetName { get; set; } - - /// <summary> - /// Target cell address used when loading to a worksheet (e.g., "A1") - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? TargetCellAddress { get; set; } - - /// <summary> - /// Whether the query was created successfully - /// </summary> - public bool QueryCreated { get; set; } - - /// <summary> - /// Whether data was loaded (true for all except ConnectionOnly) - /// </summary> - public bool DataLoaded { get; set; } - - /// <summary> - /// Number of rows loaded (0 if ConnectionOnly) - /// </summary> - public int RowsLoaded { get; set; } -} - -/// <summary> -/// Result for Power Query load operations -/// Atomic operation: Set destination + Refresh data in ONE call -/// </summary> -public class PowerQueryLoadResult : OperationResult -{ - /// <summary> - /// Name of the query - /// </summary> - public string QueryName { get; set; } = string.Empty; - - /// <summary> - /// Load destination applied - /// </summary> - public PowerQueryLoadMode LoadDestination { get; set; } - - /// <summary> - /// Target worksheet name (if applicable) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? WorksheetName { get; set; } - - /// <summary> - /// Target cell address used for the worksheet load destination (null defaults to A1) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? TargetCellAddress { get; set; } - - /// <summary> - /// Whether load configuration was applied - /// </summary> - public bool ConfigurationApplied { get; set; } - - /// <summary> - /// Whether data was refreshed - /// </summary> - public bool DataRefreshed { get; set; } - - /// <summary> - /// Number of rows loaded - /// </summary> - public int RowsLoaded { get; set; } -} - -/// <summary> -/// Result for Power Query syntax validation -/// Pre-flight syntax check before creating permanent query -/// </summary> -public class PowerQueryValidationResult : ResultBase -{ - /// <summary> - /// Whether the M code syntax is valid - /// </summary> - public bool IsValid { get; set; } - - /// <summary> - /// Validation errors (if any) - /// </summary> - public List<string> ValidationErrors { get; set; } = []; - - /// <summary> - /// M code expression that was validated - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? MCodeExpression { get; set; } -} - -/// <summary> -/// Result for listing named ranges/parameters -/// </summary> -public class NamedRangeListResult : ResultBase -{ - /// <summary> - /// List of named ranges/parameters - /// </summary> - public List<NamedRangeInfo> NamedRanges { get; set; } = []; -} - -/// <summary> -/// Information about a named range/parameter -/// </summary> -public class NamedRangeInfo -{ - /// <summary> - /// Name of the named range - /// </summary> - public string Name { get; set; } = string.Empty; - - /// <summary> - /// What the named range refers to - /// </summary> - public string RefersTo { get; set; } = string.Empty; - - /// <summary> - /// Current value - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public object? Value { get; set; } - - /// <summary> - /// Type of the value - /// </summary> - public string ValueType { get; set; } = string.Empty; -} - -/// <summary> -/// Named range value information (for Read operation) -/// </summary> -public class NamedRangeValue -{ - /// <summary> - /// Name of the named range - /// </summary> - public string Name { get; set; } = string.Empty; - - /// <summary> - /// What the named range refers to - /// </summary> - public string RefersTo { get; set; } = string.Empty; - - /// <summary> - /// Current value - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public object? Value { get; set; } - - /// <summary> - /// Type of the value - /// </summary> - public string ValueType { get; set; } = string.Empty; -} - -/// <summary> -/// Result for getting parameter value -/// </summary> -public class NamedRangeValueResult : ResultBase -{ - /// <summary> - /// Name of the named range - /// </summary> - public string NamedRangeName { get; set; } = string.Empty; - - /// <summary> - /// Current value - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public object? Value { get; set; } - - /// <summary> - /// Type of the value - /// </summary> - public string ValueType { get; set; } = string.Empty; - - /// <summary> - /// What the named range refers to - /// </summary> - public string RefersTo { get; set; } = string.Empty; -} - -/// <summary> -/// Result for listing VBA scripts -/// </summary> -public class VbaListResult : ResultBase -{ - /// <summary> - /// List of VBA scripts - /// </summary> - public List<ScriptInfo> Scripts { get; set; } = []; -} - -/// <summary> -/// Result for viewing VBA module code -/// </summary> -public class VbaViewResult : ResultBase -{ - /// <summary> - /// Module name - /// </summary> - public string ModuleName { get; set; } = string.Empty; - - /// <summary> - /// Module type - /// </summary> - public string ModuleType { get; set; } = string.Empty; - - /// <summary> - /// Complete VBA code - /// </summary> - public string Code { get; set; } = string.Empty; - - /// <summary> - /// Number of lines in the module - /// </summary> - public int LineCount { get; set; } - - /// <summary> - /// List of procedures in the module - /// </summary> - public List<string> Procedures { get; set; } = []; -} - -/// <summary> -/// Information about a VBA script -/// </summary> -public class ScriptInfo -{ - /// <summary> - /// Name of the script module - /// </summary> - public string Name { get; set; } = string.Empty; - - /// <summary> - /// Type of the script module - /// </summary> - public string Type { get; set; } = string.Empty; - - /// <summary> - /// Number of lines in the module - /// </summary> - public int LineCount { get; set; } - - /// <summary> - /// List of procedures in the module - /// </summary> - public List<string> Procedures { get; set; } = []; -} - -/// <summary> -/// File validation details for FileCommands.Test -/// </summary> -public class FileValidationInfo -{ - /// <summary> - /// Full file path being validated - /// </summary> - public string FilePath { get; set; } = string.Empty; - - /// <summary> - /// Whether the file exists - /// </summary> - public bool Exists { get; set; } - - /// <summary> - /// Size of the file in bytes - /// </summary> - public long Size { get; set; } - - /// <summary> - /// File extension - /// </summary> - public string Extension { get; set; } = string.Empty; - - /// <summary> - /// Last modification time - /// </summary> - public DateTime LastModified { get; set; } - - /// <summary> - /// Whether the file is a valid Excel workbook - /// </summary> - public bool IsValid { get; set; } - - /// <summary> - /// Whether the file is IRM/AIP-protected (OLE2 compound document format). - /// IRM-protected files are opened as read-only with Excel made visible so the user - /// can authenticate through the Information Rights Management credential prompt. - /// Use <c>show=true</c> when opening—this is set automatically by ExcelBatch when IRM is detected. - /// </summary> - public bool IsIrmProtected { get; set; } - - /// <summary> - /// Optional message describing validation outcome (missing file, invalid extension, etc.) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Message { get; set; } -} - -/// <summary> -/// Result for cell operations -/// </summary> -public class CellValueResult : ResultBase -{ - /// <summary> - /// Address of the cell (e.g., A1) - /// </summary> - public string CellAddress { get; set; } = string.Empty; - - /// <summary> - /// Current value of the cell - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public object? Value { get; set; } - - /// <summary> - /// Type of the value - /// </summary> - public string ValueType { get; set; } = string.Empty; - - /// <summary> - /// Formula in the cell, if any - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Formula { get; set; } -} - -/// <summary> -/// Result for Excel range value operations -/// </summary> -public class RangeValueResult : ResultBase -{ - /// <summary> - /// Sheet name - /// </summary> - public string SheetName { get; set; } = string.Empty; - - /// <summary> - /// Range address (e.g., A1:D10) - /// </summary> - public string RangeAddress { get; set; } = string.Empty; - - /// <summary> - /// 2D array of cell values (row-major order) - /// </summary> - public List<List<object?>> Values { get; set; } = []; - - /// <summary> - /// Number of rows in the range - /// </summary> - public int RowCount { get; set; } - - /// <summary> - /// Number of columns in the range - /// </summary> - public int ColumnCount { get; set; } -} - -/// <summary> -/// Result for Excel range formula operations -/// </summary> -public class RangeFormulaResult : ResultBase -{ - /// <summary> - /// Sheet name - /// </summary> - public string SheetName { get; set; } = string.Empty; - - /// <summary> - /// Range address (e.g., A1:D10) - /// </summary> - public string RangeAddress { get; set; } = string.Empty; - - /// <summary> - /// 2D array of cell formulas (row-major order, empty string if no formula) - /// </summary> - public List<List<string>> Formulas { get; set; } = []; - - /// <summary> - /// 2D array of cell values (calculated results) - /// </summary> - public List<List<object?>> Values { get; set; } = []; - - /// <summary> - /// Number of rows in the range - /// </summary> - public int RowCount { get; set; } - - /// <summary> - /// Number of columns in the range - /// </summary> - public int ColumnCount { get; set; } - - /// <summary> - /// Cell-level errors (e.g., #NAME?, #REF?, etc.) - /// Maps cell address to error details - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public List<RangeCellError>? CellErrors { get; set; } -} - -/// <summary> -/// Represents an error in a single cell -/// </summary> -public class RangeCellError -{ - /// <summary> - /// Cell address (e.g., "D2") - /// </summary> - public string CellAddress { get; set; } = string.Empty; - - /// <summary> - /// Row number (1-based) - /// </summary> - public int Row { get; set; } - - /// <summary> - /// Column number (1-based) - /// </summary> - public int Column { get; set; } - - /// <summary> - /// Current cell value displayed (often the error code like #NAME?) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public object? CurrentValue { get; set; } - - /// <summary> - /// Excel error code (negative integer like -2146826259) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public int? ErrorCode { get; set; } - - /// <summary> - /// Human-readable error message - /// </summary> - public string ErrorMessage { get; set; } = string.Empty; - - /// <summary> - /// Suggested fix if available - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Suggestion { get; set; } -} - -/// <summary> -/// Result for formula validation operations -/// </summary> -public class RangeFormulaValidationResult : ResultBase -{ - /// <summary> - /// Sheet name - /// </summary> - public string SheetName { get; set; } = string.Empty; - - /// <summary> - /// Range address that was validated - /// </summary> - public string RangeAddress { get; set; } = string.Empty; - - /// <summary> - /// Whether all formulas in range are valid - /// </summary> - public bool IsValid { get; set; } = true; - - /// <summary> - /// Number of formulas validated - /// </summary> - public int FormulaCount { get; set; } - - /// <summary> - /// Number of valid formulas - /// </summary> - public int ValidCount { get; set; } - - /// <summary> - /// Number of invalid/problematic formulas - /// </summary> - public int ErrorCount { get; set; } - - /// <summary> - /// 2D array of formulas that were validated - /// </summary> - public List<List<string>> Formulas { get; set; } = []; - - /// <summary> - /// Detailed validation errors by cell - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public List<FormulaValidationError>? Errors { get; set; } - - /// <summary> - /// Warnings for formulas that are technically valid but potentially problematic - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public List<FormulaValidationWarning>? Warnings { get; set; } -} - -/// <summary> -/// Represents a validation error in a formula -/// </summary> -public class FormulaValidationError -{ - /// <summary> - /// Cell address (e.g., "D2") - /// </summary> - public string CellAddress { get; set; } = string.Empty; - - /// <summary> - /// Row number (1-based) - /// </summary> - public int Row { get; set; } - - /// <summary> - /// Column number (1-based) - /// </summary> - public int Column { get; set; } - - /// <summary> - /// The formula that has the error - /// </summary> - public string Formula { get; set; } = string.Empty; - - /// <summary> - /// Error message - /// </summary> - public string Message { get; set; } = string.Empty; - - /// <summary> - /// Suggested fix - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Suggestion { get; set; } - - /// <summary> - /// Error category (e.g., "undefined-function", "missing-namespace", "syntax-error", "invalid-reference") - /// </summary> - public string Category { get; set; } = "unknown"; -} - -/// <summary> -/// Represents a warning for a formula -/// </summary> -public class FormulaValidationWarning -{ - /// <summary> - /// Cell address (e.g., "D2") - /// </summary> - public string CellAddress { get; set; } = string.Empty; - - /// <summary> - /// The formula with the warning - /// </summary> - public string Formula { get; set; } = string.Empty; - - /// <summary> - /// Warning message - /// </summary> - public string Message { get; set; } = string.Empty; - - /// <summary> - /// Warning category (e.g., "circular-reference", "array-formula", "deprecated-function") - /// </summary> - public string Category { get; set; } = "unknown"; -} - -/// <summary> -/// Result for range find operations -/// </summary> -public class RangeFindResult : ResultBase -{ - /// <summary> - /// Sheet name - /// </summary> - public string SheetName { get; set; } = string.Empty; - - /// <summary> - /// Range address that was searched - /// </summary> - public string RangeAddress { get; set; } = string.Empty; - - /// <summary> - /// Search value - /// </summary> - public string SearchValue { get; set; } = string.Empty; - - /// <summary> - /// List of matching cells - /// </summary> - public List<RangeCell> MatchingCells { get; set; } = []; -} - -/// <summary> -/// Represents a single cell in a range -/// </summary> -public class RangeCell -{ - /// <summary> - /// Cell address (e.g., "A5") - /// </summary> - public string Address { get; set; } = string.Empty; - - /// <summary> - /// Row number (1-based) - /// </summary> - public int Row { get; set; } - - /// <summary> - /// Column number (1-based) - /// </summary> - public int Column { get; set; } - - /// <summary> - /// Cell value - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public object? Value { get; set; } -} - -/// <summary> -/// Result for range information operations -/// </summary> -public class RangeInfoResult : ResultBase -{ - /// <summary> - /// Sheet name - /// </summary> - public string SheetName { get; set; } = string.Empty; - - /// <summary> - /// Absolute address from Excel COM (e.g., "$A$1:$D$10") - /// </summary> - public string Address { get; set; } = string.Empty; - - /// <summary> - /// Number of rows (Excel COM: range.Rows.Count) - /// </summary> - public int RowCount { get; set; } - - /// <summary> - /// Number of columns (Excel COM: range.Columns.Count) - /// </summary> - public int ColumnCount { get; set; } - - /// <summary> - /// Number format code (Excel COM: range.NumberFormat, first cell) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? NumberFormat { get; set; } - - /// <summary> - /// Left position of the range in points (Excel COM: range.Left). 72 points = 1 inch. - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public double? Left { get; set; } - - /// <summary> - /// Top position of the range in points (Excel COM: range.Top). 72 points = 1 inch. - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public double? Top { get; set; } - - /// <summary> - /// Width of the range in points (Excel COM: range.Width). 72 points = 1 inch. - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public double? Width { get; set; } - - /// <summary> - /// Height of the range in points (Excel COM: range.Height). 72 points = 1 inch. - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public double? Height { get; set; } -} - -/// <summary> -/// Result for hyperlink operations -/// </summary> -public class RangeHyperlinkResult : ResultBase -{ - /// <summary> - /// Sheet name - /// </summary> - public string SheetName { get; set; } = string.Empty; - - /// <summary> - /// Range or cell address - /// </summary> - public string RangeAddress { get; set; } = string.Empty; - - /// <summary> - /// List of hyperlinks - /// </summary> - public List<HyperlinkInfo> Hyperlinks { get; set; } = []; -} - -/// <summary> -/// Result for Excel range style operations -/// </summary> -public class RangeStyleResult : ResultBase -{ - /// <summary> - /// Sheet name - /// </summary> - public string SheetName { get; set; } = string.Empty; - - /// <summary> - /// Range address (e.g., A1:D10) - /// </summary> - public string RangeAddress { get; set; } = string.Empty; - - /// <summary> - /// Current style name applied to the range (first cell) - /// </summary> - public string StyleName { get; set; } = string.Empty; - - /// <summary> - /// Whether this is a built-in Excel style - /// </summary> - public bool IsBuiltInStyle { get; set; } - - /// <summary> - /// Additional style information if available - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? StyleDescription { get; set; } -} - -/// <summary> -/// Result for VBA trust operations -/// </summary> -public class VbaTrustResult : ResultBase -{ - /// <summary> - /// Whether VBA project access is trusted - /// </summary> - public bool IsTrusted { get; set; } - - /// <summary> - /// Number of VBA components found (when checking trust) - /// </summary> - public int ComponentCount { get; set; } - - /// <summary> - /// Registry paths where trust was set - /// </summary> - public List<string> RegistryPathsSet { get; set; } = []; - - /// <summary> - /// Manual setup instructions if automated setup failed - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? ManualInstructions { get; set; } -} - -/// <summary> -/// Power Query privacy level options for data combining -/// OBSOLETE: Privacy levels cannot be set programmatically. -/// Configure manually in Excel: File → Options → Privacy -/// </summary> -[Obsolete("Privacy levels not supported. Configure manually in Excel UI: File → Options → Privacy")] -public enum PowerQueryPrivacyLevel -{ - /// <summary> - /// Ignores privacy levels, allows combining any data sources (least secure) - /// </summary> - None, - - /// <summary> - /// Prevents sharing data with other sources (most secure, recommended for sensitive data) - /// </summary> - Private, - - /// <summary> - /// Data can be shared within organization (recommended for internal data) - /// </summary> - Organizational, - - /// <summary> - /// Publicly available data sources (appropriate for public APIs) - /// </summary> - Public -} - -/// <summary> -/// Result for Power Query refresh operations with error detection -/// </summary> -public class PowerQueryRefreshResult : ResultBase -{ - /// <summary> - /// Name of the query that was refreshed - /// </summary> - public string QueryName { get; set; } = string.Empty; - - /// <summary> - /// Whether query has errors after refresh attempt - /// </summary> - public bool HasErrors { get; set; } - - /// <summary> - /// List of error messages detected - /// </summary> - public List<string> ErrorMessages { get; set; } = []; - - /// <summary> - /// When the refresh was attempted - /// </summary> - public DateTime RefreshTime { get; set; } - - /// <summary> - /// Whether this is a connection-only query - /// </summary> - public bool IsConnectionOnly { get; set; } - - /// <summary> - /// Worksheet name where data was loaded (if applicable) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? LoadedToSheet { get; set; } -} - -/// <summary> -/// Result for Power Query error checking -/// </summary> -public class PowerQueryErrorCheckResult : ResultBase -{ - /// <summary> - /// Name of the query checked - /// </summary> - public string QueryName { get; set; } = string.Empty; - - /// <summary> - /// Whether errors were detected - /// </summary> - public bool HasErrors { get; set; } - - /// <summary> - /// List of error messages - /// </summary> - public List<string> ErrorMessages { get; set; } = []; - - /// <summary> - /// Category of error (Authentication, Connectivity, Privacy, Syntax, Permissions, Unknown) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? ErrorCategory { get; set; } - - /// <summary> - /// Whether this is a connection-only query - /// </summary> - public bool IsConnectionOnly { get; set; } - - /// <summary> - /// Additional message - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Message { get; set; } -} - -/// <summary> -/// Result for evaluating Power Query M code and returning results -/// </summary> -public class PowerQueryEvaluateResult : ResultBase -{ - /// <summary> - /// The M code that was evaluated - /// </summary> - public string MCode { get; set; } = ""; - - /// <summary> - /// Column names from the query result - /// </summary> - public List<string> Columns { get; set; } = []; - - /// <summary> - /// Data rows from the query result (2D array matching Columns order) - /// </summary> - public List<List<object?>> Rows { get; set; } = []; - - /// <summary> - /// Number of rows returned - /// </summary> - public int RowCount { get; set; } - - /// <summary> - /// Number of columns returned - /// </summary> - public int ColumnCount { get; set; } -} - -#region Connection Result Types - -/// <summary> -/// Result for listing connections in a workbook -/// </summary> -public class ConnectionListResult : ResultBase -{ - /// <summary> - /// List of connections in the workbook - /// </summary> - public List<ConnectionInfo> Connections { get; set; } = []; -} - -/// <summary> -/// Information about a connection -/// </summary> -public class ConnectionInfo -{ - /// <summary> - /// Connection name - /// </summary> - public string Name { get; init; } = ""; - - /// <summary> - /// Connection description - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Description { get; init; } - - /// <summary> - /// Connection type (OLEDB, ODBC, XML, Text, Web, DataFeed, Model, Worksheet, NoSource) - /// </summary> - public string Type { get; init; } = ""; - - /// <summary> - /// Last refresh date/time (if available) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public DateTime? LastRefresh { get; init; } - - /// <summary> - /// Whether the connection refreshes in background - /// </summary> - public bool BackgroundQuery { get; init; } - - /// <summary> - /// Whether the connection refreshes when file opens - /// </summary> - public bool RefreshOnFileOpen { get; init; } - - /// <summary> - /// Whether this is a Power Query connection - /// </summary> - public bool IsPowerQuery { get; init; } -} - -/// <summary> -/// Result for viewing connection details -/// </summary> -public class ConnectionViewResult : ResultBase -{ - /// <summary> - /// Connection name - /// </summary> - public string ConnectionName { get; set; } = ""; - - /// <summary> - /// Connection type (OLEDB, ODBC, XML, Text, Web, DataFeed, Model, Worksheet, NoSource) - /// </summary> - public string Type { get; set; } = ""; - - /// <summary> - /// Connection string (SANITIZED - passwords masked) - /// </summary> - public string ConnectionString { get; set; } = ""; - - /// <summary> - /// Command text (SQL query, M code reference, etc.) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? CommandText { get; set; } - - /// <summary> - /// Command type (SQL, Table, Default, etc.) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? CommandType { get; set; } - - /// <summary> - /// Whether this is a Power Query connection - /// </summary> - public bool IsPowerQuery { get; set; } - - /// <summary> - /// Full connection definition as JSON - /// </summary> - public string DefinitionJson { get; set; } = ""; -} - -/// <summary> -/// Result for getting connection properties -/// </summary> -public class ConnectionPropertiesResult : ResultBase -{ - /// <summary> - /// Connection name - /// </summary> - public string ConnectionName { get; set; } = ""; - - /// <summary> - /// Whether the connection refreshes in background - /// </summary> - public bool BackgroundQuery { get; set; } - - /// <summary> - /// Whether the connection refreshes when file opens - /// </summary> - public bool RefreshOnFileOpen { get; set; } - - /// <summary> - /// Whether password is saved with connection - /// </summary> - public bool SavePassword { get; set; } - - /// <summary> - /// Refresh period in minutes (0 = no automatic refresh) - /// </summary> - public int RefreshPeriod { get; set; } -} - -#endregion - -#region Data Model Result Types - -/// <summary> -/// Result for listing Data Model tables -/// </summary> -public class DataModelTableListResult : ResultBase -{ - /// <summary> - /// List of tables in the Data Model - /// </summary> - public List<DataModelTableInfo> Tables { get; set; } = []; -} - -/// <summary> -/// Information about a Data Model table -/// </summary> -public class DataModelTableInfo -{ - /// <summary> - /// Table name - /// </summary> - public string Name { get; init; } = ""; - - /// <summary> - /// Source query or connection name - /// </summary> - public string SourceName { get; init; } = ""; - - /// <summary> - /// Number of rows in the table - /// </summary> - public int RecordCount { get; init; } -} - -/// <summary> -/// Result for listing DAX measures -/// </summary> -public class DataModelMeasureListResult : ResultBase -{ - /// <summary> - /// List of DAX measures in the model - /// </summary> - public List<DataModelMeasureInfo> Measures { get; set; } = []; -} - -/// <summary> -/// Information about a DAX measure -/// </summary> -public class DataModelMeasureInfo -{ - /// <summary> - /// Measure name - /// </summary> - public string Name { get; init; } = ""; - - /// <summary> - /// Table name where measure is defined - /// </summary> - public string Table { get; init; } = ""; - - /// <summary> - /// DAX formula preview (truncated for display) - /// </summary> - public string FormulaPreview { get; init; } = ""; - - /// <summary> - /// Measure description (if available) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Description { get; init; } -} - -/// <summary> -/// Format information for a Data Model measure. -/// Represents the polymorphic ModelFormat* COM objects as structured JSON. -/// </summary> -public class MeasureFormatInfo -{ - /// <summary> - /// Format type: General, Currency, Decimal, Percentage, WholeNumber, Scientific, Boolean, Date - /// </summary> - public string Type { get; set; } = "General"; - - /// <summary> - /// Currency symbol (e.g., "$", "€", "£"). Only present for Currency format. - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Symbol { get; set; } - - /// <summary> - /// Number of decimal places. Present for Currency, Decimal, Percentage formats. - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public int? DecimalPlaces { get; set; } - - /// <summary> - /// Whether to use thousand separator (e.g., 1,000 vs 1000). Present for numeric formats. - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public bool? UseThousandSeparator { get; set; } -} - -/// <summary> -/// Result for viewing measure details -/// </summary> -public class DataModelMeasureViewResult : ResultBase -{ - /// <summary> - /// Measure name - /// </summary> - public string MeasureName { get; set; } = ""; - - /// <summary> - /// Table name where measure is defined - /// </summary> - public string TableName { get; set; } = ""; - - /// <summary> - /// Complete DAX formula - /// </summary> - public string DaxFormula { get; set; } = ""; - - /// <summary> - /// Measure description - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Description { get; set; } - - /// <summary> - /// Format information extracted from ModelFormat* COM objects. - /// Contains Type, Symbol, DecimalPlaces, UseThousandSeparator as applicable. - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public MeasureFormatInfo? FormatInfo { get; set; } - - /// <summary> - /// Number of characters in DAX formula - /// </summary> - public int CharacterCount { get; set; } -} - -/// <summary> -/// Result for listing model relationships -/// </summary> -public class DataModelRelationshipListResult : ResultBase -{ - /// <summary> - /// List of relationships in the model - /// </summary> - public List<DataModelRelationshipInfo> Relationships { get; set; } = []; -} - -/// <summary> -/// Result for reading a single relationship -/// </summary> -public class DataModelRelationshipViewResult : ResultBase -{ - /// <summary> - /// The relationship details - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public DataModelRelationshipInfo? Relationship { get; set; } -} - -/// <summary> -/// Information about a table relationship -/// </summary> -public class DataModelRelationshipInfo -{ - /// <summary> - /// Source table name (foreign key side) - /// </summary> - public string FromTable { get; init; } = ""; - - /// <summary> - /// Source column name (foreign key) - /// </summary> - public string FromColumn { get; init; } = ""; - - /// <summary> - /// Target table name (primary key side) - /// </summary> - public string ToTable { get; init; } = ""; - - /// <summary> - /// Target column name (primary key) - /// </summary> - public string ToColumn { get; init; } = ""; - - /// <summary> - /// Whether this relationship is active - /// </summary> - public bool IsActive { get; init; } -} - -/// <summary> -/// Result for DAX formula validation -/// </summary> -public class DataModelValidationResult : ResultBase -{ - /// <summary> - /// Whether the DAX formula is valid - /// </summary> - public bool IsValid { get; set; } - - /// <summary> - /// Validation error message (if not valid) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? ValidationError { get; set; } - - /// <summary> - /// DAX formula that was validated - /// </summary> - public string DaxFormula { get; set; } = ""; -} - -/// <summary> -/// Result for listing calculated columns -/// </summary> -public class DataModelCalculatedColumnListResult : ResultBase -{ - /// <summary> - /// List of calculated columns in the model - /// </summary> - public List<DataModelCalculatedColumnInfo> CalculatedColumns { get; set; } = []; -} - -/// <summary> -/// Information about a calculated column -/// </summary> -public class DataModelCalculatedColumnInfo -{ - /// <summary> - /// Column name - /// </summary> - public string Name { get; init; } = ""; - - /// <summary> - /// Table name where column is defined - /// </summary> - public string Table { get; init; } = ""; - - /// <summary> - /// DAX formula preview (truncated for display) - /// </summary> - public string FormulaPreview { get; init; } = ""; - - /// <summary> - /// Data type of the column - /// </summary> - public string DataType { get; init; } = ""; - - /// <summary> - /// Column description (if available) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Description { get; init; } -} - -/// <summary> -/// Result for viewing calculated column details -/// </summary> -public class DataModelCalculatedColumnViewResult : ResultBase -{ - /// <summary> - /// Column name - /// </summary> - public string ColumnName { get; set; } = ""; - - /// <summary> - /// Table name where column is defined - /// </summary> - public string TableName { get; set; } = ""; - - /// <summary> - /// Complete DAX formula - /// </summary> - public string DaxFormula { get; set; } = ""; - - /// <summary> - /// Column description - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Description { get; set; } - - /// <summary> - /// Data type of the column - /// </summary> - public string DataType { get; set; } = ""; - - /// <summary> - /// Number of characters in DAX formula - /// </summary> - public int CharacterCount { get; set; } -} - -/// <summary> -/// Result for DAX EVALUATE query execution -/// </summary> -public class DaxEvaluateResult : ResultBase -{ - /// <summary> - /// The DAX EVALUATE query that was executed - /// </summary> - public string DaxQuery { get; set; } = ""; - - /// <summary> - /// Column names from the query result (fully qualified: Table[Column]) - /// </summary> - public List<string> Columns { get; set; } = []; - - /// <summary> - /// Data rows from the query result (2D array matching Columns order) - /// </summary> - public List<List<object?>> Rows { get; set; } = []; - - /// <summary> - /// Number of rows returned - /// </summary> - public int RowCount { get; set; } - - /// <summary> - /// Number of columns returned - /// </summary> - public int ColumnCount { get; set; } -} - -/// <summary> -/// Result for DMV (Dynamic Management View) query execution -/// </summary> -public class DmvQueryResult : ResultBase -{ - /// <summary> - /// The DMV query that was executed - /// </summary> - public string DmvQuery { get; set; } = ""; - - /// <summary> - /// Column names from the query result - /// </summary> - public List<string> Columns { get; set; } = []; - - /// <summary> - /// Data rows from the query result (2D array matching Columns order) - /// </summary> - public List<List<object?>> Rows { get; set; } = []; - - /// <summary> - /// Number of rows returned - /// </summary> - public int RowCount { get; set; } - - /// <summary> - /// Number of columns returned - /// </summary> - public int ColumnCount { get; set; } -} - -/// <summary> -/// Result for getting DAX query information from a table -/// </summary> -public class TableDaxInfoResult : ResultBase -{ - /// <summary> - /// Name of the Excel Table - /// </summary> - public string TableName { get; set; } = ""; - - /// <summary> - /// DAX EVALUATE query backing this table (if any) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? DaxQuery { get; set; } - - /// <summary> - /// Whether this table is backed by a DAX query - /// </summary> - public bool HasDaxConnection { get; set; } - - /// <summary> - /// Name of the model connection (if DAX-backed) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? ModelConnectionName { get; set; } -} - -#endregion - -#region Table (ListObject) Results - -/// <summary> -/// Result for listing Excel Tables -/// </summary> -public class TableListResult : ResultBase -{ - /// <summary> - /// List of Excel Tables in the workbook - /// </summary> - public List<TableInfo> Tables { get; set; } = []; -} - -/// <summary> -/// Result for getting detailed information about an Excel Table -/// </summary> -public class TableInfoResult : ResultBase -{ - /// <summary> - /// Detailed information about the Excel Table - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public TableInfo? Table { get; set; } -} - -/// <summary> -/// Information about an Excel Table (ListObject) -/// </summary> -public class TableInfo -{ - /// <summary> - /// Name of the table - /// </summary> - public string Name { get; set; } = string.Empty; - - /// <summary> - /// Worksheet containing the table - /// </summary> - public string SheetName { get; set; } = string.Empty; - - /// <summary> - /// Range address of the table (e.g., "A1:D10") - /// </summary> - public string Range { get; set; } = string.Empty; - - /// <summary> - /// Whether the table has headers - /// </summary> - public bool HasHeaders { get; set; } = true; - - /// <summary> - /// Table style name (e.g., "TableStyleMedium2") - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? TableStyle { get; set; } - - /// <summary> - /// Number of rows (excluding header) - /// </summary> - public int RowCount { get; set; } - - /// <summary> - /// Number of columns - /// </summary> - public int ColumnCount { get; set; } - - /// <summary> - /// Column names (if table has headers) - /// </summary> - public List<string> Columns { get; set; } = []; - - /// <summary> - /// Whether the table has a total row - /// </summary> - public bool ShowTotals { get; set; } -} - -/// <summary> -/// Result for reading Excel Table data -/// </summary> -public class TableDataResult : ResultBase -{ - /// <summary> - /// Name of the table - /// </summary> - public string TableName { get; set; } = string.Empty; - - /// <summary> - /// Column headers - /// </summary> - public List<string> Headers { get; set; } = []; - - /// <summary> - /// Data rows (each row is a list of cell values) - /// </summary> - public List<List<object?>> Data { get; set; } = []; - - /// <summary> - /// Number of rows (excluding header) - /// </summary> - public int RowCount { get; set; } - - /// <summary> - /// Number of columns - /// </summary> - public int ColumnCount { get; set; } -} - -/// <summary> -/// Result for getting filter state of an Excel Table -/// </summary> -public class TableFilterResult : ResultBase -{ - /// <summary> - /// Name of the table - /// </summary> - public string TableName { get; set; } = string.Empty; - - /// <summary> - /// Filter information for each column - /// </summary> - public List<ColumnFilter> ColumnFilters { get; set; } = []; - - /// <summary> - /// Whether any filters are active - /// </summary> - public bool HasActiveFilters { get; set; } -} - -/// <summary> -/// Filter information for a table column -/// </summary> -public class ColumnFilter -{ - /// <summary> - /// Column name - /// </summary> - public string ColumnName { get; set; } = string.Empty; - - /// <summary> - /// Column index (1-based) - /// </summary> - public int ColumnIndex { get; set; } - - /// <summary> - /// Whether this column has an active filter - /// </summary> - public bool IsFiltered { get; set; } - - /// <summary> - /// Filter criteria (if single criteria) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Criteria { get; set; } - - /// <summary> - /// Filter values (if multiple values) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public List<string>? FilterValues { get; set; } -} - -/// <summary> -/// Excel Table regions for structured references -/// </summary> -public enum TableRegion -{ - /// <summary> - /// Entire table including headers, data, and totals (TableName[#All]) - /// </summary> - All, - - /// <summary> - /// Data rows only, excluding headers and totals (TableName[#Data]) - /// </summary> - Data, - - /// <summary> - /// Header row only (TableName[#Headers]) - /// </summary> - Headers, - - /// <summary> - /// Totals row only (TableName[#Totals]) - /// </summary> - Totals, - - /// <summary> - /// This row in formula context (TableName[@]) - /// </summary> - ThisRow -} - -/// <summary> -/// Result for getting structured reference information for a table region -/// </summary> -public class TableStructuredReferenceResult : ResultBase -{ - /// <summary> - /// Name of the table - /// </summary> - public string TableName { get; set; } = string.Empty; - - /// <summary> - /// Table region requested - /// </summary> - public TableRegion Region { get; set; } - - /// <summary> - /// Excel range address for the region (e.g., "$A$1:$D$100") - /// </summary> - public string RangeAddress { get; set; } = string.Empty; - - /// <summary> - /// Structured reference formula (e.g., "SalesTable[#Data]") - /// </summary> - public string StructuredReference { get; set; } = string.Empty; - - /// <summary> - /// Sheet name where the table is located - /// </summary> - public string SheetName { get; set; } = string.Empty; - - /// <summary> - /// Column name (if requesting specific column reference) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? ColumnName { get; set; } - - /// <summary> - /// Number of rows in the region - /// </summary> - public int RowCount { get; set; } - - /// <summary> - /// Number of columns in the region - /// </summary> - public int ColumnCount { get; set; } -} - -/// <summary> -/// Sort column specification for table sorting -/// </summary> -public class TableSortColumn -{ - /// <summary> - /// Column name to sort by - /// </summary> - public string ColumnName { get; set; } = string.Empty; - - /// <summary> - /// Whether to sort in ascending order (true) or descending (false) - /// </summary> - public bool Ascending { get; set; } = true; -} - -#endregion - -#region Hyperlink Results - -/// <summary> -/// Information about a hyperlink in an Excel cell -/// </summary> -public class HyperlinkInfo -{ - /// <summary> - /// Cell address containing the hyperlink - /// </summary> - public string CellAddress { get; set; } = string.Empty; - - /// <summary> - /// Hyperlink URL or file path - /// </summary> - public string Address { get; set; } = string.Empty; - - /// <summary> - /// Sub-address within the target (e.g., sheet reference) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? SubAddress { get; set; } - - /// <summary> - /// Display text (visible text in cell) - /// </summary> - public string DisplayText { get; set; } = string.Empty; - - /// <summary> - /// Tooltip/ScreenTip text - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? ScreenTip { get; set; } - - /// <summary> - /// Whether the hyperlink points to another location in the workbook - /// </summary> - public bool IsInternal { get; set; } -} - -/// <summary> -/// Result for listing hyperlinks in a worksheet -/// </summary> -public class HyperlinkListResult : ResultBase -{ - /// <summary> - /// List of hyperlinks in the worksheet - /// </summary> - public List<HyperlinkInfo> Hyperlinks { get; set; } = []; - - /// <summary> - /// Total count of hyperlinks - /// </summary> - public int Count { get; set; } - - /// <summary> - /// Sheet name - /// </summary> - public string SheetName { get; set; } = string.Empty; -} - -/// <summary> -/// Result for getting hyperlink information from a specific cell -/// </summary> -public class HyperlinkInfoResult : ResultBase -{ - /// <summary> - /// Hyperlink information (null if no hyperlink exists) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public HyperlinkInfo? Hyperlink { get; set; } - - /// <summary> - /// Whether a hyperlink exists at the specified cell - /// </summary> - public bool HasHyperlink { get; set; } - - /// <summary> - /// Sheet name - /// </summary> - public string SheetName { get; set; } = string.Empty; - - /// <summary> - /// Cell address - /// </summary> - public string CellAddress { get; set; } = string.Empty; -} - -#endregion - -#region Number Formatting Results - -/// <summary> -/// Result for range number format operations -/// </summary> -public class RangeNumberFormatResult : ResultBase -{ - /// <summary> - /// Sheet name - /// </summary> - public string SheetName { get; set; } = string.Empty; - - /// <summary> - /// Range address (e.g., A1:D10) - /// </summary> - public string RangeAddress { get; set; } = string.Empty; - - /// <summary> - /// 2D array of number format codes (matches range dimensions) - /// </summary> - public List<List<string>> Formats { get; set; } = []; - - /// <summary> - /// Number of rows in the range - /// </summary> - public int RowCount { get; set; } - - /// <summary> - /// Number of columns in the range - /// </summary> - public int ColumnCount { get; set; } -} - -#endregion - -#region Validation Results - -/// <summary> -/// Result for range validation operations -/// </summary> -public class RangeValidationResult : ResultBase -{ - /// <summary> - /// Sheet name - /// </summary> - public string SheetName { get; set; } = string.Empty; - - /// <summary> - /// Range address - /// </summary> - public string RangeAddress { get; set; } = string.Empty; - - /// <summary> - /// Whether the range has validation - /// </summary> - public bool HasValidation { get; set; } - - /// <summary> - /// Validation type (list, whole, decimal, date, time, textlength, custom) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? ValidationType { get; set; } - - /// <summary> - /// Validation operator (between, equal, greaterthan, etc.) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? ValidationOperator { get; set; } - - /// <summary> - /// First formula/value - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Formula1 { get; set; } - - /// <summary> - /// Second formula/value (for Between operator) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Formula2 { get; set; } - - /// <summary> - /// Whether to ignore blank cells - /// </summary> - public bool IgnoreBlank { get; set; } - - /// <summary> - /// Whether to show input message - /// </summary> - public bool ShowInputMessage { get; set; } - - /// <summary> - /// Input message title - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? InputTitle { get; set; } - - /// <summary> - /// Input message text - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? InputMessage { get; set; } - - /// <summary> - /// Whether to show error alert - /// </summary> - public bool ShowErrorAlert { get; set; } - - /// <summary> - /// Error alert style (stop, warning, information) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? ErrorStyle { get; set; } - - /// <summary> - /// Error alert title - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? ErrorTitle { get; set; } - - /// <summary> - /// Error alert message text - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? ValidationErrorMessage { get; set; } -} - -#endregion - -#region Cell Merge and Protection Results - -/// <summary> -/// Result for range merge information -/// </summary> -public class RangeMergeInfoResult : ResultBase -{ - /// <summary> - /// Sheet name - /// </summary> - public string SheetName { get; set; } = string.Empty; - - /// <summary> - /// Range address - /// </summary> - public string RangeAddress { get; set; } = string.Empty; - - /// <summary> - /// Whether the range contains merged cells - /// </summary> - public bool IsMerged { get; set; } -} - -/// <summary> -/// Result for cell lock information -/// </summary> -public class RangeLockInfoResult : ResultBase -{ - /// <summary> - /// Sheet name - /// </summary> - public string SheetName { get; set; } = string.Empty; - - /// <summary> - /// Range address - /// </summary> - public string RangeAddress { get; set; } = string.Empty; - - /// <summary> - /// Whether the cells are locked - /// </summary> - public bool IsLocked { get; set; } -} - -#endregion - -#region Diag Results - -/// <summary> -/// Result for diagnostic commands. Returns echoed parameters and metadata. -/// </summary> -public class DiagResult : ResultBase -{ - /// <summary> - /// The diagnostic action that was performed - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Action { get; set; } - - /// <summary> - /// Echoed message (for echo action) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Message { get; set; } - - /// <summary> - /// Echoed tag (for echo action) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Tag { get; set; } - - /// <summary> - /// ISO 8601 timestamp of the response - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Timestamp { get; set; } - - /// <summary> - /// Echoed parameters as key-value pairs (for validate-params action) - /// </summary> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public Dictionary<string, object?>? Parameters { get; set; } -} - -#endregion - - diff --git a/src/ExcelMcp.Core/Models/TableStylePresets.cs b/src/ExcelMcp.Core/Models/TableStylePresets.cs deleted file mode 100644 index 45187cc8..00000000 --- a/src/ExcelMcp.Core/Models/TableStylePresets.cs +++ /dev/null @@ -1,111 +0,0 @@ -namespace Sbroenne.ExcelMcp.Core.Models; - -/// <summary> -/// Common Excel Table (ListObject) style names for LLM convenience. -/// These are standard Excel built-in table styles that can be used with SetStyle. -/// </summary> -public static class TableStylePresets -{ - // === LIGHT STYLES (subtle colors, minimal formatting) === - - /// <summary>Light style 1 - subtle gray banding</summary> - public const string Light1 = "TableStyleLight1"; - - /// <summary>Light style 2 - blue accents</summary> - public const string Light2 = "TableStyleLight2"; - - /// <summary>Light style 3 - orange accents</summary> - public const string Light3 = "TableStyleLight3"; - - /// <summary>Light style 8 - teal accents</summary> - public const string Light8 = "TableStyleLight8"; - - /// <summary>Light style 9 - blue banding</summary> - public const string Light9 = "TableStyleLight9"; - - /// <summary>Light style 10 - orange banding</summary> - public const string Light10 = "TableStyleLight10"; - - /// <summary>Light style 11 - gray banding</summary> - public const string Light11 = "TableStyleLight11"; - - /// <summary>Light style 16 - professional blue</summary> - public const string Light16 = "TableStyleLight16"; - - /// <summary>Light style 21 - clean minimal</summary> - public const string Light21 = "TableStyleLight21"; - - // === MEDIUM STYLES (balanced colors, most popular) === - - /// <summary>Medium style 1 - professional blue</summary> - public const string Medium1 = "TableStyleMedium1"; - - /// <summary>Medium style 2 - orange accents (POPULAR)</summary> - public const string Medium2 = "TableStyleMedium2"; - - /// <summary>Medium style 3 - green accents</summary> - public const string Medium3 = "TableStyleMedium3"; - - /// <summary>Medium style 4 - yellow accents</summary> - public const string Medium4 = "TableStyleMedium4"; - - /// <summary>Medium style 5 - blue accents</summary> - public const string Medium5 = "TableStyleMedium5"; - - /// <summary>Medium style 6 - teal accents</summary> - public const string Medium6 = "TableStyleMedium6"; - - /// <summary>Medium style 7 - red accents</summary> - public const string Medium7 = "TableStyleMedium7"; - - /// <summary>Medium style 9 - gray/blue professional (POPULAR)</summary> - public const string Medium9 = "TableStyleMedium9"; - - /// <summary>Medium style 10 - green/white clean</summary> - public const string Medium10 = "TableStyleMedium10"; - - /// <summary>Medium style 11 - orange/white vibrant</summary> - public const string Medium11 = "TableStyleMedium11"; - - /// <summary>Medium style 15 - blue banding</summary> - public const string Medium15 = "TableStyleMedium15"; - - /// <summary>Medium style 16 - orange banding</summary> - public const string Medium16 = "TableStyleMedium16"; - - /// <summary>Medium style 20 - professional gray</summary> - public const string Medium20 = "TableStyleMedium20"; - - /// <summary>Medium style 28 - modern minimal</summary> - public const string Medium28 = "TableStyleMedium28"; - - // === DARK STYLES (high contrast, bold colors) === - - /// <summary>Dark style 1 - dark blue header</summary> - public const string Dark1 = "TableStyleDark1"; - - /// <summary>Dark style 2 - dark orange header</summary> - public const string Dark2 = "TableStyleDark2"; - - /// <summary>Dark style 3 - dark gray header</summary> - public const string Dark3 = "TableStyleDark3"; - - /// <summary>Dark style 7 - dark red header</summary> - public const string Dark7 = "TableStyleDark7"; - - /// <summary>Dark style 9 - blue/white high contrast</summary> - public const string Dark9 = "TableStyleDark9"; - - /// <summary>Dark style 10 - orange/white high contrast</summary> - public const string Dark10 = "TableStyleDark10"; - - /// <summary>Dark style 11 - gray/white high contrast</summary> - public const string Dark11 = "TableStyleDark11"; - - // === NONE (remove table styling) === - - /// <summary>No table style - removes all formatting</summary> - public const string None = ""; -} - - diff --git a/src/ExcelMcp.McpServer/GlobalUsings.cs b/src/ExcelMcp.McpServer/GlobalUsings.cs deleted file mode 100644 index 756c858c..00000000 --- a/src/ExcelMcp.McpServer/GlobalUsings.cs +++ /dev/null @@ -1,5 +0,0 @@ -// Global usings for ExcelMcp.McpServer -// Required for source generator compatibility - generated MCP tools need these namespaces - -global using Sbroenne.ExcelMcp.Core.Models.Actions; -global using Sbroenne.ExcelMcp.Generated; diff --git a/src/ExcelMcp.McpServer/README.md b/src/ExcelMcp.McpServer/README.md deleted file mode 100644 index 375299fb..00000000 --- a/src/ExcelMcp.McpServer/README.md +++ /dev/null @@ -1,118 +0,0 @@ -# ExcelMcp - Model Context Protocol Server for Excel - -<!-- mcp-name: io.github.sbroenne/mcp-server-excel --> -mcp-name: io.github.sbroenne/mcp-server-excel - -[![NuGet](https://img.shields.io/nuget/v/Sbroenne.ExcelMcp.McpServer.svg)](https://www.nuget.org/packages/Sbroenne.ExcelMcp.McpServer) -[![NuGet Downloads](https://img.shields.io/nuget/dt/Sbroenne.ExcelMcp.McpServer.svg)](https://www.nuget.org/packages/Sbroenne.ExcelMcp.McpServer) -[![GitHub](https://img.shields.io/badge/GitHub-Repository-blue.svg)](https://github.com/sbroenne/mcp-server-excel) - -**Control Excel with Natural Language** through AI assistants like GitHub Copilot, Claude, and ChatGPT. This MCP server enables AI-powered Excel automation for Power Query, DAX measures, VBA macros, PivotTables, Charts, and more. - -➡️ **[Learn more and see examples](https://sbroenne.github.io/mcp-server-excel/)** - -**🛡️ 100% Safe - Uses Excel's Native COM API** - -Unlike third-party libraries that manipulate `.xlsx` files (risking corruption), ExcelMcp uses **Excel's official COM automation API**. This guarantees zero risk of file corruption while you work interactively with live Excel files - see your changes happen in real-time. - -**🔗 Unified Service Architecture** - The MCP Server forwards all requests to the shared ExcelMCP Service, enabling CLI and MCP to share sessions transparently. - -**CLI also available:** The MCP Server tool (`mcp-excel`) and CLI tool (`excelcli`) are published as separate .NET tools. Install `Sbroenne.ExcelMcp.McpServer` for MCP clients, and optionally install `Sbroenne.ExcelMcp.CLI` for scripting/RPA workflows. - -**Requirements:** Windows OS + Excel 2016+ - -## 🚀 Installation - -**Quick Setup Options:** - -1. **VS Code Extension** - [One-click install](https://marketplace.visualstudio.com/items?itemName=sbroenne.excel-mcp) for GitHub Copilot -2. **Manual Install** - Works with Claude Desktop, Cursor, Cline, Windsurf, and other MCP clients -3. **MCP Registry** - Find us at [registry.modelcontextprotocol.io](https://registry.modelcontextprotocol.io/servers/io.github.sbroenne/mcp-server-excel) - -**Manual Installation (All MCP Clients):** - -Requires .NET 10 Runtime or SDK - -```powershell -# Install MCP Server tool -dotnet tool install --global Sbroenne.ExcelMcp.McpServer - -# Optional: install CLI tool separately -dotnet tool install --global Sbroenne.ExcelMcp.CLI -``` - -**Supported AI Assistants:** -- ✅ GitHub Copilot (VS Code, Visual Studio) -- ✅ Claude Desktop -- ✅ Cursor -- ✅ Cline (VS Code Extension) -- ✅ Windsurf -- ✅ Any MCP-compatible client - -📖 **Detailed setup instructions:** [Installation Guide](https://github.com/sbroenne/mcp-server-excel/blob/main/docs/INSTALLATION.md) - -🎯 **Quick config examples:** [examples/mcp-configs/](https://github.com/sbroenne/mcp-server-excel/tree/main/examples/mcp-configs) - -## 🛠️ What You Can Do - -**25 specialized tools with 225 operations:** - -- 🔄 **Power Query** (1 tool, 11 ops) - Atomic workflows, M code management, load destinations -- 📊 **Data Model/DAX** (2 tools, 18 ops) - Measures, relationships, model structure -- 🎨 **Excel Tables** (2 tools, 27 ops) - Lifecycle, filtering, sorting, structured references -- 📈 **PivotTables** (3 tools, 30 ops) - Creation, fields, aggregations, calculated members/fields -- 📉 **Charts** (2 tools, 26 ops) - Create, configure, series, formatting, data labels, trendlines -- 📝 **VBA** (1 tool, 6 ops) - Modules, execution, version control -- 📋 **Ranges** (4 tools, 42 ops) - Values, formulas, formatting, validation, protection -- 📄 **Worksheets** (2 tools, 16 ops) - Lifecycle, colors, visibility, cross-workbook moves -- 🔌 **Connections** (1 tool, 9 ops) - OLEDB/ODBC management and refresh -- 🏷️ **Named Ranges** (1 tool, 6 ops) - Parameters and configuration -- 📁 **Files** (1 tool, 6 ops) - Session management, workbook creation, IRM/AIP-protected file support -- 🧮 **Calculation Mode** (1 tool, 3 ops) - Get/set calculation mode and trigger recalculation -- 🎚️ **Slicers** (1 tool, 8 ops) - Interactive filtering for PivotTables and Tables -- 🎨 **Conditional Formatting** (1 tool, 2 ops) - Rules and clearing -- 📸 **Screenshot** (1 tool, 2 ops) - Capture ranges/sheets as PNG for visual verification -- 🪧 **Window Management** (1 tool, 9 ops) - Show/hide Excel, arrange, position, status bar feedback - -📚 **[Complete Feature Reference →](../../FEATURES.md)** - Detailed documentation of all 225 operations - -**AI-Powered Workflows:** -- 💬 Natural language Excel commands through GitHub Copilot, Claude, or ChatGPT -- 🔄 Optimize Power Query M code for performance and readability -- 📊 Build complex DAX measures with AI guidance -- 📋 Automate repetitive data transformations and formatting -- 👀 **Show Excel Mode** - Say "Show me Excel while you work" to watch changes live - - ---- - -## 💡 Example Use Cases - -**"Create a sales tracker with Date, Product, Quantity, Unit Price, and Total columns"** -→ AI creates the workbook, adds headers, enters sample data, and builds formulas - -**"Create a PivotTable from this data showing total sales by Product, then add a chart"** -→ AI creates PivotTable, configures fields, and adds a linked visualization - -**"Import products.csv with Power Query, load to Data Model, create a Total Revenue measure"** -→ AI imports data, adds to Power Pivot, and creates DAX measures for analysis - -**"Create a slicer for the Region field so I can filter interactively"** -→ AI adds slicers connected to PivotTables or Tables for point-and-click filtering - -**"Put this data in A1: Name, Age / Alice, 30 / Bob, 25"** -→ AI writes data directly to cells using natural delimiters you provide - ---- - -## 📋 Additional Resources - -- **[GitHub Repository](https://github.com/sbroenne/mcp-server-excel)** - Source code, issues, discussions -- **[Installation Guide](https://github.com/sbroenne/mcp-server-excel/blob/main/docs/INSTALLATION.md)** - Detailed setup for all platforms -- **[VS Code Extension](https://marketplace.visualstudio.com/items?itemName=sbroenne.excel-mcp)** - One-click installation -- **[CLI Documentation](https://github.com/sbroenne/mcp-server-excel/blob/main/src/ExcelMcp.CLI/README.md)** - Comprehensive commands for RPA and CI/CD automation - -**License:** MIT -**Privacy:** [PRIVACY.md](https://github.com/sbroenne/mcp-server-excel/blob/main/PRIVACY.md) -**Platform:** Windows only (requires Excel 2016+) -**Support:** [GitHub Issues](https://github.com/sbroenne/mcp-server-excel/issues) diff --git a/src/ExcelMcp.McpServer/Telemetry/ExcelMcpTelemetry.cs b/src/ExcelMcp.McpServer/Telemetry/ExcelMcpTelemetry.cs deleted file mode 100644 index ed83a2c0..00000000 --- a/src/ExcelMcp.McpServer/Telemetry/ExcelMcpTelemetry.cs +++ /dev/null @@ -1,240 +0,0 @@ -// Copyright (c) Sbroenne. All rights reserved. -// Licensed under the MIT License. - -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using Microsoft.ApplicationInsights; -using Microsoft.ApplicationInsights.DataContracts; - -namespace Sbroenne.ExcelMcp.McpServer.Telemetry; - -/// <summary> -/// Centralized telemetry helper for ExcelMcp MCP Server. -/// Provides usage tracking and performance metrics via Application Insights SDK. -/// -/// Telemetry types: -/// - TrackEvent: Tool usage analytics (which tools, actions, success/failure rates) -/// - TrackRequest: Performance metrics (duration, response codes for Performance blade) -/// - TrackException: Unhandled exceptions (for Failures blade) -/// -/// User/Session context is set by ExcelMcpTelemetryInitializer on all telemetry items. -/// View data in Azure Portal: Logs blade with Kusto queries on customEvents/requests tables. -/// </summary> -public static class ExcelMcpTelemetry -{ - /// <summary> - /// Unique session ID for correlating telemetry within a single MCP server process. - /// Changes each time the MCP server starts. - /// </summary> - public static readonly string SessionId = Guid.NewGuid().ToString("N")[..8]; - - /// <summary> - /// Stable anonymous user ID based on machine identity. - /// Persists across sessions for the same machine, enabling user-level analytics - /// without collecting personally identifiable information. - /// </summary> - public static readonly string UserId = GenerateAnonymousUserId(); - - /// <summary> - /// Application Insights TelemetryClient for sending Custom Events. - /// Enables Users/Sessions analytics in Azure Portal. - /// </summary> - private static TelemetryClient? _telemetryClient; - - /// <summary> - /// Sets the TelemetryClient instance for sending Custom Events. - /// Called by Program.cs during startup. Also tracks a session start event. - /// </summary> - internal static void SetTelemetryClient(TelemetryClient client) - { - _telemetryClient = client; - - // Track session start to ensure Users/Sessions blades have data - // This event fires once per MCP server process startup - TrackSessionStart(); - } - - /// <summary> - /// Tracks the start of an MCP server session. - /// Uses TrackEvent directly - ITelemetryInitializer sets user/session context. - /// This ensures Users/Sessions blades have data even if no tools are invoked. - /// </summary> - private static void TrackSessionStart() - { - if (_telemetryClient == null) return; - - _telemetryClient.TrackEvent("SessionStart", new Dictionary<string, string> - { - ["SessionId"] = SessionId, - ["AppVersion"] = GetVersion() - }); - } - - /// <summary> - /// Flushes any buffered telemetry to Application Insights. - /// CRITICAL: Must be called before application exits to ensure telemetry is not lost. - /// Application Insights SDK buffers telemetry and sends in batches - without explicit flush, - /// short-lived processes like MCP servers may terminate before telemetry is transmitted. - /// </summary> - public static void Flush() - { - if (_telemetryClient == null) return; - - try - { - // Flush with timeout to avoid hanging on shutdown - // 5 seconds is typically sufficient for small batches - _telemetryClient.FlushAsync(CancellationToken.None).Wait(TimeSpan.FromSeconds(5)); - } - catch (Exception) - { - // Don't let telemetry flush failure crash the application - } - } - - /// <summary> - /// Gets the Application Insights connection string (embedded at build time). - /// </summary> - public static string? GetConnectionString() - { - // Connection string is embedded at build time from Directory.Build.props.user - // Returns null if not set (placeholder value starts with __) - if (string.IsNullOrEmpty(TelemetryConfig.ConnectionString) || - TelemetryConfig.ConnectionString.StartsWith("__", StringComparison.Ordinal)) - { - return null; - } - return TelemetryConfig.ConnectionString; - } - - /// <summary> - /// Tracks a tool invocation with usage and performance metrics. - /// - TrackEvent: For tool usage analytics (customEvents table) - /// - TrackRequest: For performance metrics (requests table, Performance blade) - /// </summary> - /// <param name="toolName">The MCP tool name (e.g., "range")</param> - /// <param name="action">The action performed (e.g., "get-values")</param> - /// <param name="durationMs">Duration in milliseconds</param> - /// <param name="success">Whether the operation succeeded</param> - /// <param name="excelPath">Optional Excel file path (will be hashed for privacy)</param> - public static void TrackToolInvocation(string toolName, string action, long durationMs, bool success, string? excelPath = null) - { - if (_telemetryClient == null) return; - - var operationName = $"{toolName}/{action}"; - var startTime = DateTimeOffset.UtcNow.AddMilliseconds(-durationMs); - var duration = TimeSpan.FromMilliseconds(durationMs); - - var properties = new Dictionary<string, string> - { - ["Tool"] = toolName, - ["Action"] = action, - ["Success"] = success.ToString() - }; - - // Add hashed file path for grouping (if provided) - if (!string.IsNullOrEmpty(excelPath)) - { - properties["FileSessionId"] = HashFilePath(excelPath); - } - - var metrics = new Dictionary<string, double> - { - ["DurationMs"] = durationMs - }; - - // Track as customEvent for analytics (tool usage, parameters, success/failure) - _telemetryClient.TrackEvent(operationName, properties, metrics); - - // Track as request for Performance blade, Failures blade, Smart Detection - var request = new RequestTelemetry - { - Name = operationName, - Timestamp = startTime, - Duration = duration, - ResponseCode = success ? "200" : "500", - Success = success - }; - - // Copy properties to request for consistent filtering - foreach (var prop in properties) - { - request.Properties[prop.Key] = prop.Value; - } - - _telemetryClient.TrackRequest(request); - } - - /// <summary> - /// Tracks an unhandled exception. - /// Only call this for exceptions that escape all catch blocks (true bugs/crashes). - /// </summary> - /// <param name="exception">The unhandled exception</param> - /// <param name="source">Source of the exception (e.g., "AppDomain.UnhandledException")</param> - public static void TrackUnhandledException(Exception exception, string source) - { - if (_telemetryClient == null || exception == null) return; - - // Redact sensitive data from exception - var (type, _, _) = SensitiveDataRedactor.RedactException(exception); - - // Track as exception in Application Insights (for Failures blade) - _telemetryClient.TrackException(exception, new Dictionary<string, string> - { - ["Source"] = source, - ["ExceptionType"] = type, - ["AppVersion"] = GetVersion() - }); - } - - /// <summary> - /// Gets the application version from assembly metadata. - /// </summary> - private static string GetVersion() - { - return Assembly.GetExecutingAssembly() - .GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion - ?? Assembly.GetExecutingAssembly().GetName().Version?.ToString() - ?? "1.0.0"; - } - - /// <summary> - /// Generates a stable anonymous user ID based on machine identity. - /// Uses a hash of machine name and user profile path to create a consistent - /// identifier that persists across sessions without collecting PII. - /// </summary> - private static string GenerateAnonymousUserId() - { - try - { - // Combine machine-specific values that are stable but not personally identifiable - var machineIdentity = $"{Environment.MachineName}|{Environment.UserName}|{Environment.OSVersion.Platform}"; - - // Create a SHA256 hash and take the first 16 characters - var bytes = Encoding.UTF8.GetBytes(machineIdentity); - var hash = SHA256.HashData(bytes); - return Convert.ToHexString(hash)[..16].ToLowerInvariant(); - } - catch (Exception) - { - // Fallback to a random ID if machine identity cannot be determined - return Guid.NewGuid().ToString("N")[..16]; - } - } - - /// <summary> - /// Hashes a file path for privacy-preserving grouping. - /// Enables grouping telemetry by file without exposing actual file paths. - /// </summary> - /// <param name="filePath">The file path to hash</param> - /// <returns>First 12 characters of SHA256 hash (lowercase hex)</returns> - private static string HashFilePath(string filePath) - { - var bytes = Encoding.UTF8.GetBytes(filePath.ToLowerInvariant()); - var hash = SHA256.HashData(bytes); - return Convert.ToHexString(hash)[..12].ToLowerInvariant(); - } -} - - diff --git a/src/ExcelMcp.McpServer/Telemetry/ExcelMcpTelemetryInitializer.cs b/src/ExcelMcp.McpServer/Telemetry/ExcelMcpTelemetryInitializer.cs deleted file mode 100644 index ec28f3f7..00000000 --- a/src/ExcelMcp.McpServer/Telemetry/ExcelMcpTelemetryInitializer.cs +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright (c) Sbroenne. All rights reserved. -// Licensed under the MIT License. - -using System.Reflection; - -using Microsoft.ApplicationInsights.Channel; -using Microsoft.ApplicationInsights.Extensibility; - -namespace Sbroenne.ExcelMcp.McpServer.Telemetry; - -/// <summary> -/// Telemetry initializer that sets User.Id, Session.Id, and Component.Version for Application Insights. -/// This enables the Users and Sessions blades in the Azure Portal and ensures correct version reporting. -/// </summary> -public sealed class ExcelMcpTelemetryInitializer : ITelemetryInitializer -{ - private readonly string _userId; - private readonly string _sessionId; - private readonly string _version; - private readonly string _roleInstance; - - /// <summary> - /// Initializes a new instance of the <see cref="ExcelMcpTelemetryInitializer"/> class. - /// </summary> - public ExcelMcpTelemetryInitializer() - { - _userId = ExcelMcpTelemetry.UserId; - _sessionId = ExcelMcpTelemetry.SessionId; - _version = GetVersion(); - _roleInstance = GenerateAnonymousRoleInstance(); - } - - /// <summary> - /// Initializes the telemetry item with user, session, and version context. - /// </summary> - /// <param name="telemetry">The telemetry item to initialize.</param> - public void Initialize(ITelemetry telemetry) - { - // Set user context for Users blade - if (string.IsNullOrEmpty(telemetry.Context.User.Id)) - { - telemetry.Context.User.Id = _userId; - } - - // Set session context for Sessions blade - if (string.IsNullOrEmpty(telemetry.Context.Session.Id)) - { - telemetry.Context.Session.Id = _sessionId; - } - - // Set cloud role for better grouping in Application Map - if (string.IsNullOrEmpty(telemetry.Context.Cloud.RoleName)) - { - telemetry.Context.Cloud.RoleName = "ExcelMcp.McpServer"; - } - - // Set role instance to anonymized value (instead of machine name) - telemetry.Context.Cloud.RoleInstance = _roleInstance; - - // Set version explicitly - ALWAYS override SDK auto-detection - // SDK picks up Excel COM version (15.0.0.0) instead of our assembly version - telemetry.Context.Component.Version = _version; - } - - /// <summary> - /// Generates an anonymous role instance identifier based on machine identity. - /// Uses the same hash as UserId but with a different prefix for clarity. - /// </summary> - private static string GenerateAnonymousRoleInstance() - { - // Reuse the anonymous user ID (already a hash of machine identity) - return $"instance-{ExcelMcpTelemetry.UserId[..8]}"; - } - - /// <summary> - /// Gets the application version from assembly metadata. - /// </summary> - private static string GetVersion() - { - return Assembly.GetExecutingAssembly() - .GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion - ?? Assembly.GetExecutingAssembly().GetName().Version?.ToString() - ?? "1.0.0"; - } -} - - diff --git a/src/ExcelMcp.McpServer/Telemetry/SensitiveDataRedactor.cs b/src/ExcelMcp.McpServer/Telemetry/SensitiveDataRedactor.cs deleted file mode 100644 index b8217d22..00000000 --- a/src/ExcelMcp.McpServer/Telemetry/SensitiveDataRedactor.cs +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright (c) Sbroenne. All rights reserved. -// Licensed under the MIT License. - -using System.Text.RegularExpressions; - -namespace Sbroenne.ExcelMcp.McpServer.Telemetry; - -/// <summary> -/// Utility class that redacts sensitive data from telemetry before it's sent. -/// Removes file paths, connection strings, credentials, and other PII. -/// </summary> -public static partial class SensitiveDataRedactor -{ - // Patterns for sensitive data detection - private static readonly Regex FilePathPattern = CreateFilePathRegex(); - private static readonly Regex UncPathPattern = CreateUncPathRegex(); - private static readonly Regex ConnectionStringSecretPattern = CreateConnectionStringSecretRegex(); - private static readonly Regex CredentialPattern = CreateCredentialRegex(); - private static readonly Regex EmailPattern = CreateEmailRegex(); - - // Redaction markers - private const string RedactedPath = "[REDACTED_PATH]"; - private const string RedactedSecret = "[REDACTED]"; - private const string RedactedEmail = "[REDACTED_EMAIL]"; - - /// <summary> - /// Redacts all sensitive data from the given string. - /// </summary> - public static string RedactSensitiveData(string value) - { - if (string.IsNullOrEmpty(value)) - return value; - - var result = value; - - // Redact file paths (Windows drive letters) - result = FilePathPattern.Replace(result, RedactedPath); - - // Redact UNC paths - result = UncPathPattern.Replace(result, RedactedPath); - - // Redact connection string secrets (Password=, pwd=, secret=, key=, token=) - result = ConnectionStringSecretPattern.Replace(result, match => - $"{match.Groups[1].Value}={RedactedSecret}"); - - // Redact credentials in URLs (user:pass@host) - result = CredentialPattern.Replace(result, match => - $"{match.Groups[1].Value}{RedactedSecret}@{match.Groups[2].Value}"); - - // Redact email addresses - result = EmailPattern.Replace(result, RedactedEmail); - - return result; - } - - /// <summary> - /// Redacts sensitive data from an exception for safe logging. - /// Returns exception type, redacted message, and redacted stack trace. - /// </summary> - public static (string Type, string Message, string? StackTrace) RedactException(Exception ex) - { - var type = ex.GetType().Name; - var message = RedactSensitiveData(ex.Message); - var stackTrace = ex.StackTrace != null ? RedactSensitiveData(ex.StackTrace) : null; - - return (type, message, stackTrace); - } - - // Source-generated regex for better performance - - [GeneratedRegex(@"[A-Za-z]:\\[^\s""'<>|*?\r\n]+", RegexOptions.Compiled)] - private static partial Regex CreateFilePathRegex(); - - [GeneratedRegex(@"\\\\[^\s""'<>|*?\r\n]+", RegexOptions.Compiled)] - private static partial Regex CreateUncPathRegex(); - - [GeneratedRegex(@"(Password|pwd|secret|key|token|apikey|api_key|access_token|connectionstring)\s*=\s*[^;""'\s]+", RegexOptions.IgnoreCase | RegexOptions.Compiled)] - private static partial Regex CreateConnectionStringSecretRegex(); - - [GeneratedRegex(@"(https?://)[^:]+:[^@]+@([^\s/]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled)] - private static partial Regex CreateCredentialRegex(); - - [GeneratedRegex(@"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", RegexOptions.Compiled)] - private static partial Regex CreateEmailRegex(); -} - - diff --git a/src/ExcelMcp.McpServer/Tools/ExcelFileTool.cs b/src/ExcelMcp.McpServer/Tools/ExcelFileTool.cs deleted file mode 100644 index 357f62a0..00000000 --- a/src/ExcelMcp.McpServer/Tools/ExcelFileTool.cs +++ /dev/null @@ -1,367 +0,0 @@ -using System.ComponentModel; -using System.Text.Json; -using ModelContextProtocol.Server; -using Sbroenne.ExcelMcp.Core.Commands; - -namespace Sbroenne.ExcelMcp.McpServer.Tools; - -/// <summary> -/// Excel file management tool for MCP server. -/// </summary> -[McpServerToolType] -public static partial class ExcelFileTool -{ - /// <summary> - /// File and session management for Excel automation. - /// - /// WORKFLOW: open → use sessionId with other tools → close (save=true to persist changes). - /// NEW FILES: Use 'create' action to create file AND start session in one call. - /// - /// SESSION REUSE: Call 'list' first to check for existing sessions. - /// If file is already open, reuse existing sessionId instead of opening again. - /// - /// IMPORTANT: Before closing, check 'list' action - wait for canClose=true (no active operations). - /// If show=true was used, confirm with user before closing visible Excel windows. - /// - /// TIMEOUT: Each operation has a 5-min default timeout. Use timeoutSeconds to customize - /// for long-running operations (data refresh, large queries). Operations timing out - /// trigger aggressive cleanup and may leave Excel in inconsistent state. - /// - /// IRM/AIP FILES: Files protected with Azure Information Protection are detected automatically. - /// They are opened as read-only with Excel forced visible for credential authentication. - /// Use 'test' action first to check isIrmProtected before attempting to open. - /// </summary> - /// <param name="action">The file operation to perform</param> - /// <param name="path">Full Windows path to Excel file (.xlsx or .xlsm). ASK USER for the path - do not guess or use placeholder usernames. Required for: open, create, test</param> - /// <param name="session_id">Session ID returned from 'open' or 'create'. Required for: close. Used by all other tools.</param> - /// <param name="save">Whether to save changes when closing. Default: false (discard changes)</param> - /// <param name="show">Whether to make Excel window visible. Default: false (hidden automation)</param> - /// <param name="timeout_seconds">Maximum time in seconds for any operation in this session. Default: 300 (5 min). Range: 10-3600. Used for: open, create</param> - [McpServerTool(Name = "file", Title = "File Operations", Destructive = true)] - [McpMeta("category", "session")] - [McpMeta("requiresSession", false)] - public static partial string ExcelFile( - FileAction action, - [DefaultValue(null)] string? path, - [DefaultValue(null)] string? session_id, - [DefaultValue(false)] bool save, - [DefaultValue(false)] bool show, - [DefaultValue(300)] int timeout_seconds) - { - // Validate timeout range - if (timeout_seconds < 10 || timeout_seconds > 3600) - { - return JsonSerializer.Serialize(new - { - success = false, - errorMessage = $"timeout_seconds must be between 10 and 3600 seconds, got {timeout_seconds}", - isError = true - }, ExcelToolsBase.JsonOptions); - } - - var timeout = TimeSpan.FromSeconds(timeout_seconds); - - return ExcelToolsBase.ExecuteToolAction( - "file", - action.ToActionString(), - path, - () => - { - // Switch directly on enum for compile-time exhaustiveness checking (CS8524) - return action switch - { - FileAction.List => ListSessions(), - FileAction.Open => OpenSessionAsync(path!, show, timeout), - FileAction.Close => CloseSessionAsync(session_id!, save), - FileAction.Create => CreateSessionAsync(path!, show, timeout), - FileAction.CloseWorkbook => CloseWorkbook(path!), - FileAction.Test => TestFileAsync(path!), - _ => throw new ArgumentException($"Unknown action: {action} ({action.ToActionString()})", nameof(action)) - }; - }); - } - - /// <summary> - /// Opens an Excel file and creates a new session via the ExcelMCP Service. - /// Returns sessionId that must be used for all subsequent operations. - /// </summary> - private static string OpenSessionAsync(string path, bool show, TimeSpan timeout) - { - if (string.IsNullOrWhiteSpace(path)) - { - throw new ArgumentException("path is required for 'open' action", nameof(path)); - } - - // Validate Windows path format before any file operations - var pathError = ExcelToolsBase.ValidateWindowsPath(path); - if (pathError != null) - { - return pathError; - } - - if (!File.Exists(path)) - { - return JsonSerializer.Serialize(new - { - success = false, - errorMessage = $"File not found: {path}", - filePath = path, - isError = true - }, ExcelToolsBase.JsonOptions); - } - - var timeoutSeconds = (int)timeout.TotalSeconds; - var response = ServiceBridge.ServiceBridge.SendAsync( - "session.open", - null, - new { filePath = path, show = show, timeoutSeconds }, - timeoutSeconds - ).GetAwaiter().GetResult(); - - if (!response.Success) - { - return JsonSerializer.Serialize(new - { - success = false, - errorMessage = response.ErrorMessage ?? "Failed to open session", - filePath = path, - isError = true - }, ExcelToolsBase.JsonOptions); - } - - // Parse service response and transform sessionId → session_id for MCP snake_case compatibility - if (!string.IsNullOrEmpty(response.Result)) - { - try - { - using var doc = JsonDocument.Parse(response.Result); - if (doc.RootElement.TryGetProperty("sessionId", out var sessionIdProp)) - { - var sessionId = sessionIdProp.GetString(); - string? filePath = doc.RootElement.TryGetProperty("filePath", out var fp) ? fp.GetString() : path; - return JsonSerializer.Serialize(new - { - success = true, - session_id = sessionId, - filePath - }, ExcelToolsBase.JsonOptions); - } - } - catch (JsonException) - { - // Fall through to return raw result - } - - return response.Result; - } - - // Fallback: response should have contained sessionId - return JsonSerializer.Serialize(new - { - success = true, - filePath = path, - show - }, ExcelToolsBase.JsonOptions); - } - - /// <summary> - /// Closes an active session via the ExcelMCP Service with optional save. - /// By default, saves changes before closing to prevent data loss. - /// Set save=false to discard changes. - /// </summary> - private static string CloseSessionAsync(string sessionId, bool save) - { - if (string.IsNullOrWhiteSpace(sessionId)) - { - throw new ArgumentException("sessionId is required for 'close' action", nameof(sessionId)); - } - - var response = ServiceBridge.ServiceBridge.SendAsync( - "session.close", - sessionId, - new { save } - ).GetAwaiter().GetResult(); - - if (!response.Success) - { - return JsonSerializer.Serialize(new - { - success = false, - session_id = sessionId, - errorMessage = response.ErrorMessage ?? "Failed to close session", - isError = true - }, ExcelToolsBase.JsonOptions); - } - - return response.Result ?? JsonSerializer.Serialize(new - { - success = true, - session_id = sessionId, - saved = save - }, ExcelToolsBase.JsonOptions); - } - - /// <summary> - /// Creates a new empty Excel file AND opens a session in one operation. - /// Returns sessionId that must be used for all subsequent operations. - /// Directory must exist - will not be created automatically. - /// </summary> - private static string CreateSessionAsync(string path, bool show, TimeSpan timeout) - { - if (string.IsNullOrWhiteSpace(path)) - { - throw new ArgumentException("path is required for 'create' action", nameof(path)); - } - - // Validate Windows path format before any file operations - var pathError = ExcelToolsBase.ValidateWindowsPath(path); - if (pathError != null) - { - return pathError; - } - - // Determine if macro-enabled from extension - bool macroEnabled = path.EndsWith(".xlsm", StringComparison.OrdinalIgnoreCase); - var extension = macroEnabled ? ".xlsm" : ".xlsx"; - if (!path.EndsWith(extension, StringComparison.OrdinalIgnoreCase)) - { - path = Path.ChangeExtension(path, extension); - } - - var timeoutSeconds = (int)timeout.TotalSeconds; - var response = ServiceBridge.ServiceBridge.SendAsync( - "session.create", - null, - new { filePath = path, macroEnabled, show = show, timeoutSeconds }, - timeoutSeconds - ).GetAwaiter().GetResult(); - - if (!response.Success) - { - return JsonSerializer.Serialize(new - { - success = false, - errorMessage = response.ErrorMessage ?? "Failed to create session", - filePath = path, - isError = true - }, ExcelToolsBase.JsonOptions); - } - - // Parse service response and transform sessionId → session_id for MCP snake_case compatibility - if (!string.IsNullOrEmpty(response.Result)) - { - try - { - using var doc = JsonDocument.Parse(response.Result); - if (doc.RootElement.TryGetProperty("sessionId", out var sessionIdProp)) - { - var sessionId = sessionIdProp.GetString(); - string? filePath = doc.RootElement.TryGetProperty("filePath", out var fp) ? fp.GetString() : path; - return JsonSerializer.Serialize(new - { - success = true, - session_id = sessionId, - filePath - }, ExcelToolsBase.JsonOptions); - } - } - catch (JsonException) - { - // Fall through to return raw result - } - - return response.Result; - } - - // Fallback: response should have contained session_id - return JsonSerializer.Serialize(new - { - success = true, - filePath = path, - macroEnabled, - show - }, ExcelToolsBase.JsonOptions); - } - - /// <summary> - /// Closes the workbook (no-op with new single-instance architecture). - /// LLM Pattern: This action is kept for backward compatibility but does nothing. - /// With single-instance sessions, workbooks are automatically closed after each operation. - /// </summary> - private static string CloseWorkbook(string path) - { - return JsonSerializer.Serialize(new - { - success = true, - filePath = path - }, ExcelToolsBase.JsonOptions); - } - - /// <summary> - /// Lists all active sessions with status info. Lightweight operation - no Excel COM calls. - /// LLM Pattern: Use this to verify sessions and check for running operations before closing. - /// </summary> - private static string ListSessions() - { - var response = ServiceBridge.ServiceBridge.SendAsync("session.list").GetAwaiter().GetResult(); - - if (!response.Success) - { - return JsonSerializer.Serialize(new - { - success = false, - errorMessage = response.ErrorMessage ?? "Failed to list sessions", - isError = true - }, ExcelToolsBase.JsonOptions); - } - - return response.Result ?? JsonSerializer.Serialize(new - { - success = true, - sessions = Array.Empty<object>(), - count = 0 - }, ExcelToolsBase.JsonOptions); - } - - /// <summary> - /// Tests if an Excel file exists and is valid without opening it via Excel COM. - /// LLM Pattern: Use this for discovery/connectivity testing before running operations. - /// </summary> - private static string TestFileAsync(string path) - { - if (string.IsNullOrWhiteSpace(path)) - { - throw new ArgumentException("path is required for 'test' action", nameof(path)); - } - - // Validate Windows path format before any file operations - var pathError = ExcelToolsBase.ValidateWindowsPath(path); - if (pathError != null) - { - return pathError; - } - - var fileCommands = new FileCommands(); - var info = fileCommands.Test(path); - - return JsonSerializer.Serialize(new - { - success = info.IsValid, - filePath = info.FilePath, - exists = info.Exists, - isValid = info.IsValid, - extension = info.Extension, - size = info.Size, - lastModified = info.LastModified, - isIrmProtected = info.IsIrmProtected, - message = info.Message, - isError = info.IsValid ? (bool?)null : true - }, ExcelToolsBase.JsonOptions); - } -} - - - - - - diff --git a/src/ExcelMcp.McpServer/Tools/ExcelScreenshotTool.cs b/src/ExcelMcp.McpServer/Tools/ExcelScreenshotTool.cs deleted file mode 100644 index d5e7f4a9..00000000 --- a/src/ExcelMcp.McpServer/Tools/ExcelScreenshotTool.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System.ComponentModel; -using System.Text.Json; -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; -using Sbroenne.ExcelMcp.Core.Commands.Screenshot; - -namespace Sbroenne.ExcelMcp.McpServer.Tools; - -/// <summary> -/// Manual MCP tool for screenshot operations. -/// Returns ImageContentBlock for proper MCP image handling. -/// </summary> -[McpServerToolType] -public static class ExcelScreenshotTool -{ - /// <summary> - /// Capture Excel worksheet content as images for visual verification. - /// Uses Excel's built-in rendering to capture all visual elements (formatting, charts, conditional formatting). - /// capture: specific range (requires rangeAddress). - /// capture-sheet: entire used area of worksheet. - /// Returns the image directly as MCP ImageContent. - /// Use after operations to visually verify results. - /// quality: Medium (default, JPEG 75% scale, ~4-8x smaller), High (PNG full scale), Low (JPEG 50% scale). - /// </summary> - [McpServerTool(Name = "screenshot", Title = "Screenshot", Destructive = false)] - [McpMeta("category", "visualization")] - [McpMeta("requiresSession", true)] - [Description("Capture Excel worksheet content as images for visual verification. " + - "Uses Excel's built-in rendering to capture all visual elements (formatting, charts, conditional formatting). " + - "capture: specific range (requires rangeAddress). " + - "capture-sheet: entire used area of worksheet. " + - "Returns the image directly as MCP ImageContent. " + - "Use after operations to visually verify results. " + - "quality: Medium (default, JPEG 75% scale, ~4-8x smaller than High), High (PNG full scale), Low (JPEG 50% scale).")] - public static CallToolResult ExcelScreenshot( - [Description("The action to perform")] ScreenshotAction action, - [Description("Session ID from file 'open' action")] string session_id, - [DefaultValue(null)] string? sheet_name, - [DefaultValue("A1:Z30")] string range_address, - [DefaultValue(ScreenshotQuality.Medium)] ScreenshotQuality quality) - { - // Forward to service and get JSON response - var jsonResponse = ExcelToolsBase.ExecuteToolAction( - "screenshot", - ServiceRegistry.Screenshot.ToActionString(action), - () => ServiceRegistry.Screenshot.RouteAction( - action, - session_id, - ExcelToolsBase.ForwardToServiceFunc, - sheetName: sheet_name, - rangeAddress: range_address, - quality: quality - )); - - // Parse the JSON response to extract image data - try - { - var result = JsonSerializer.Deserialize<ScreenshotResult>(jsonResponse, ExcelToolsBase.JsonOptions); - - if (result is null || !result.Success || string.IsNullOrEmpty(result.ImageBase64)) - { - // Return error as text content - return new CallToolResult - { - IsError = true, - Content = [new TextContentBlock { Text = jsonResponse }] - }; - } - - // Return image as ImageContentBlock + metadata as TextContentBlock - var metadata = $"Screenshot: {result.RangeAddress} on '{result.SheetName}' ({result.Width}x{result.Height}px)"; - - return new CallToolResult - { - Content = - [ - ImageContentBlock.FromBytes(Convert.FromBase64String(result.ImageBase64), result.MimeType), - new TextContentBlock - { - Text = metadata - } - ] - }; - } - catch (JsonException) - { - // If JSON parsing fails, return the raw response as error - return new CallToolResult - { - IsError = true, - Content = [new TextContentBlock { Text = jsonResponse }] - }; - } - } -} diff --git a/src/ExcelMcp.McpServer/Tools/ExcelWorksheetTool.cs b/src/ExcelMcp.McpServer/Tools/ExcelWorksheetTool.cs deleted file mode 100644 index 020cb725..00000000 --- a/src/ExcelMcp.McpServer/Tools/ExcelWorksheetTool.cs +++ /dev/null @@ -1,145 +0,0 @@ -using System.ComponentModel; -using System.Text.Json; -using ModelContextProtocol.Server; - -namespace Sbroenne.ExcelMcp.McpServer.Tools; - -/// <summary> -/// Excel worksheet management tool for MCP server. -/// Handles both session-based operations (list, create, rename, delete, move, copy) -/// and atomic cross-file operations (copy-to-file, move-to-file). -/// </summary> -[McpServerToolType] -public static partial class ExcelWorksheetTool -{ - /// <summary> - /// Worksheet lifecycle: create, rename, copy, delete, move. - /// ATOMIC OPERATIONS: copy-to-file and move-to-file don't require a session (open/close automatically). - /// POSITIONING: Use before OR after (not both) to place sheet relative to another. - /// Use worksheet_style for tab colors and visibility. - /// </summary> - /// <param name="action">The action to perform</param> - /// <param name="session_id">Session ID from file 'open' action (required for: list, create, rename, delete, move, copy. Not required for: copy-to-file, move-to-file)</param> - /// <param name="sheet_name">Name of the worksheet (required for: create, rename, delete, move, copy)</param> - /// <param name="source_name">Name of the source worksheet (required for: copy)</param> - /// <param name="target_name">New name for the worksheet (required for: rename, copy)</param> - /// <param name="file_path">Optional file path when batch contains multiple workbooks</param> - /// <param name="source_file">Full path to the source workbook (required for: copy-to-file, move-to-file)</param> - /// <param name="source_sheet">Name of the sheet to copy (required for: copy-to-file, move-to-file)</param> - /// <param name="target_file">Full path to the target workbook (required for: copy-to-file, move-to-file)</param> - /// <param name="target_sheet_name">Optional: New name for the copied sheet (default: keeps original name)</param> - /// <param name="before_sheet">Optional: Position before this sheet</param> - /// <param name="after_sheet">Optional: Position after this sheet</param> - [McpServerTool(Name = "worksheet", Title = "Worksheet Operations", Destructive = true)] - [McpMeta("category", "structure")] - [McpMeta("requiresSession", false)] // Session is optional - depends on the action - [Description("Worksheet lifecycle: create, rename, copy, delete, move. ATOMIC OPERATIONS: copy-to-file and move-to-file don't require a session (open/close automatically). POSITIONING: Use before OR after (not both) to place sheet relative to another. Use worksheet_style for tab colors and visibility.")] - public static string ExcelWorksheet( - [Description("The action to perform")] SheetAction action, - [DefaultValue(null)] string? session_id, - [DefaultValue(null)] string? sheet_name, - [DefaultValue(null)] string? source_name, - [DefaultValue(null)] string? target_name, - [DefaultValue(null)] string? file_path, - [DefaultValue(null)] string? source_file, - [DefaultValue(null)] string? source_sheet, - [DefaultValue(null)] string? target_file, - [DefaultValue(null)] string? target_sheet_name, - [DefaultValue(null)] string? before_sheet, - [DefaultValue(null)] string? after_sheet) - { - return ExcelToolsBase.ExecuteToolAction( - "worksheet", - ServiceRegistry.Sheet.ToActionString(action), - () => - { - // Atomic operations don't require a session - if (action == SheetAction.CopyToFile || action == SheetAction.MoveToFile) - { - return action switch - { - SheetAction.CopyToFile => - ServiceRegistry.Sheet.RouteAction( - action, - "", // No session for atomic operation - ExcelToolsBase.ForwardToServiceFunc, - sourceFile: source_file, - sourceSheet: source_sheet, - targetFile: target_file, - targetSheetName: target_sheet_name, - beforeSheet: before_sheet, - afterSheet: after_sheet), - SheetAction.MoveToFile => - ServiceRegistry.Sheet.RouteAction( - action, - "", // No session for atomic operation - ExcelToolsBase.ForwardToServiceFunc, - sourceFile: source_file, - sourceSheet: source_sheet, - targetFile: target_file, - beforeSheet: before_sheet, - afterSheet: after_sheet), - _ => throw new ArgumentException($"Unknown atomic action: {action}"), - }; - } - - // Validate session_id for non-atomic operations - if (string.IsNullOrWhiteSpace(session_id)) - { - return JsonSerializer.Serialize(new - { - success = false, - errorMessage = "session_id is required for this action. Use file 'open' action to start a session.", - isError = true - }, ExcelToolsBase.JsonOptions); - } - - // Session-based operations - return action switch - { - SheetAction.List => - ServiceRegistry.Sheet.RouteAction( - action, - session_id, - ExcelToolsBase.ForwardToServiceFunc, - filePath: file_path), - SheetAction.Create => - ServiceRegistry.Sheet.RouteAction( - action, - session_id, - ExcelToolsBase.ForwardToServiceFunc, - sheetName: sheet_name, - filePath: file_path), - SheetAction.Rename => - ServiceRegistry.Sheet.RouteAction( - action, - session_id, - ExcelToolsBase.ForwardToServiceFunc, - oldName: sheet_name, - newName: target_name), - SheetAction.Delete => - ServiceRegistry.Sheet.RouteAction( - action, - session_id, - ExcelToolsBase.ForwardToServiceFunc, - sheetName: sheet_name), - SheetAction.Copy => - ServiceRegistry.Sheet.RouteAction( - action, - session_id, - ExcelToolsBase.ForwardToServiceFunc, - sourceName: source_name, - targetName: target_name), - SheetAction.Move => - ServiceRegistry.Sheet.RouteAction( - action, - session_id, - ExcelToolsBase.ForwardToServiceFunc, - sheetName: sheet_name, - beforeSheet: before_sheet, - afterSheet: after_sheet), - _ => throw new ArgumentException($"Unknown action: {action} ({ServiceRegistry.Sheet.ToActionString(action)})", nameof(action)) - }; - }); - } -} diff --git a/src/ExcelMcp.Build.Tasks/GenerateSkillFile.cs b/src/PptMcp.Build.Tasks/GenerateSkillFile.cs similarity index 99% rename from src/ExcelMcp.Build.Tasks/GenerateSkillFile.cs rename to src/PptMcp.Build.Tasks/GenerateSkillFile.cs index d22296b6..9f6f6c62 100644 --- a/src/ExcelMcp.Build.Tasks/GenerateSkillFile.cs +++ b/src/PptMcp.Build.Tasks/GenerateSkillFile.cs @@ -3,7 +3,7 @@ using Scriban.Runtime; using System.Text.Json; -namespace Sbroenne.ExcelMcp.Build.Tasks; +namespace PptMcp.Build.Tasks; /// <summary> /// MSBuild task that generates skill files from Scriban templates. diff --git a/src/ExcelMcp.Build.Tasks/ExcelMcp.Build.Tasks.csproj b/src/PptMcp.Build.Tasks/PptMcp.Build.Tasks.csproj similarity index 81% rename from src/ExcelMcp.Build.Tasks/ExcelMcp.Build.Tasks.csproj rename to src/PptMcp.Build.Tasks/PptMcp.Build.Tasks.csproj index 67d7c386..92a5805a 100644 --- a/src/ExcelMcp.Build.Tasks/ExcelMcp.Build.Tasks.csproj +++ b/src/PptMcp.Build.Tasks/PptMcp.Build.Tasks.csproj @@ -4,8 +4,8 @@ <TargetFramework>netstandard2.0</TargetFramework> <LangVersion>latest</LangVersion> <Nullable>enable</Nullable> - <RootNamespace>Sbroenne.ExcelMcp.Build.Tasks</RootNamespace> - <AssemblyName>Sbroenne.ExcelMcp.Build.Tasks</AssemblyName> + <RootNamespace>PptMcp.Build.Tasks</RootNamespace> + <AssemblyName>PptMcp.Build.Tasks</AssemblyName> <!-- Build task specific settings --> <GeneratePackageOnBuild>false</GeneratePackageOnBuild> @@ -13,6 +13,9 @@ <!-- Exclude from main solution build by default - built on demand --> <IsPackable>false</IsPackable> + + <!-- Suppress vulnerability warnings for build-time MSBuild SDK packages --> + <NoWarn>$(NoWarn);NU1903</NoWarn> </PropertyGroup> <ItemGroup> diff --git a/src/ExcelMcp.Build.Tasks/SkillTemplateModel.cs b/src/PptMcp.Build.Tasks/SkillTemplateModel.cs similarity index 89% rename from src/ExcelMcp.Build.Tasks/SkillTemplateModel.cs rename to src/PptMcp.Build.Tasks/SkillTemplateModel.cs index 63b88d19..ebe011ee 100644 --- a/src/ExcelMcp.Build.Tasks/SkillTemplateModel.cs +++ b/src/PptMcp.Build.Tasks/SkillTemplateModel.cs @@ -1,4 +1,4 @@ -namespace Sbroenne.ExcelMcp.Build.Tasks; +namespace PptMcp.Build.Tasks; /// <summary> /// Model passed to Scriban templates for skill generation. @@ -17,11 +17,11 @@ public class SkillTemplateModel } /// <summary> -/// Represents a CLI command parsed from excelcli --help output. +/// Represents a CLI command parsed from pptcli --help output. /// </summary> public class CliCommand { - /// <summary>Command name (e.g., "worksheet", "range")</summary> + /// <summary>Command name (e.g., "slide", "range")</summary> public string Name { get; set; } = ""; /// <summary>Command description from interface XML doc</summary> diff --git a/src/ExcelMcp.CLI/Commands/BatchCommand.cs b/src/PptMcp.CLI/Commands/BatchCommand.cs similarity index 98% rename from src/ExcelMcp.CLI/Commands/BatchCommand.cs rename to src/PptMcp.CLI/Commands/BatchCommand.cs index a88f2427..94910e36 100644 --- a/src/ExcelMcp.CLI/Commands/BatchCommand.cs +++ b/src/PptMcp.CLI/Commands/BatchCommand.cs @@ -1,11 +1,11 @@ using System.ComponentModel; using System.Text.Json; using System.Text.Json.Serialization; -using Sbroenne.ExcelMcp.CLI.Infrastructure; -using Sbroenne.ExcelMcp.Service; +using PptMcp.CLI.Infrastructure; +using PptMcp.Service; using Spectre.Console.Cli; -namespace Sbroenne.ExcelMcp.CLI.Commands; +namespace PptMcp.CLI.Commands; /// <summary> /// Executes multiple CLI commands in a single process launch. diff --git a/src/PptMcp.CLI/Commands/DiagCommands.cs b/src/PptMcp.CLI/Commands/DiagCommands.cs new file mode 100644 index 00000000..94869a07 --- /dev/null +++ b/src/PptMcp.CLI/Commands/DiagCommands.cs @@ -0,0 +1,127 @@ +using System.ComponentModel; +using System.Text.Json; +using PptMcp.CLI.Infrastructure; +using PptMcp.Service; +using Spectre.Console.Cli; + +namespace PptMcp.CLI.Commands; + +// ============================================================================ +// DIAG COMMANDS - Diagnostic/infrastructure commands (no PowerPoint required) +// ============================================================================ + +internal sealed class DiagPingCommand : AsyncCommand +{ + public override async Task<int> ExecuteAsync(CommandContext context, CancellationToken cancellationToken) + { + using var client = await DaemonAutoStart.EnsureAndConnectAsync(cancellationToken); + var response = await client.SendAsync(new ServiceRequest { Command = "diag.ping" }, cancellationToken); + + if (response.Success) + { + Console.WriteLine(response.Result); + return 0; + } + + Console.WriteLine(JsonSerializer.Serialize(new { success = false, error = response.ErrorMessage }, ServiceProtocol.JsonOptions)); + return 1; + } +} + +internal sealed class DiagEchoCommand : AsyncCommand<DiagEchoCommand.Settings> +{ + public override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(settings.Message)) + { + Console.WriteLine(JsonSerializer.Serialize(new { success = false, error = "Parameter 'message' is required for echo" }, ServiceProtocol.JsonOptions)); + return 1; + } + + using var client = await DaemonAutoStart.EnsureAndConnectAsync(cancellationToken); + var args = new Dictionary<string, object?> { ["message"] = settings.Message }; + if (!string.IsNullOrEmpty(settings.Tag)) + args["tag"] = settings.Tag; + + var response = await client.SendAsync(new ServiceRequest + { + Command = "diag.echo", + Args = JsonSerializer.Serialize(args, ServiceProtocol.JsonOptions) + }, cancellationToken); + + if (response.Success) + { + Console.WriteLine(response.Result); + return 0; + } + + Console.WriteLine(JsonSerializer.Serialize(new { success = false, error = response.ErrorMessage }, ServiceProtocol.JsonOptions)); + return 1; + } + + internal sealed class Settings : CommandSettings + { + [CommandOption("--message <MESSAGE>")] + [Description("Message to echo back")] + public string? Message { get; init; } + + [CommandOption("--tag <TAG>")] + [Description("Optional tag to include")] + public string? Tag { get; init; } + } +} + +internal sealed class DiagValidateParamsCommand : AsyncCommand<DiagValidateParamsCommand.Settings> +{ + public override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(settings.Name)) + { + Console.WriteLine(JsonSerializer.Serialize(new { success = false, error = "Parameter 'name' is required for validate-params" }, ServiceProtocol.JsonOptions)); + return 1; + } + + using var client = await DaemonAutoStart.EnsureAndConnectAsync(cancellationToken); + var response = await client.SendAsync(new ServiceRequest + { + Command = "diag.validate-params", + Args = JsonSerializer.Serialize(new Dictionary<string, object?> + { + ["name"] = settings.Name, + ["count"] = settings.Count, + ["label"] = settings.Label, + ["verbose"] = settings.Verbose + }, ServiceProtocol.JsonOptions) + }, cancellationToken); + + if (response.Success) + { + Console.WriteLine(response.Result); + return 0; + } + + Console.WriteLine(JsonSerializer.Serialize(new { success = false, error = response.ErrorMessage }, ServiceProtocol.JsonOptions)); + return 1; + } + + internal sealed class Settings : CommandSettings + { + [CommandOption("--name <NAME>")] + [Description("Name parameter (required)")] + public string? Name { get; init; } + + [CommandOption("--count <COUNT>")] + [Description("Count parameter")] + [DefaultValue(0)] + public int Count { get; init; } + + [CommandOption("--label <LABEL>")] + [Description("Optional label")] + public string? Label { get; init; } + + [CommandOption("--verbose")] + [Description("Verbose flag")] + [DefaultValue(false)] + public bool Verbose { get; init; } + } +} diff --git a/src/ExcelMcp.CLI/Commands/ListActionsCommand.cs b/src/PptMcp.CLI/Commands/ListActionsCommand.cs similarity index 93% rename from src/ExcelMcp.CLI/Commands/ListActionsCommand.cs rename to src/PptMcp.CLI/Commands/ListActionsCommand.cs index e0394e74..600ab30b 100644 --- a/src/ExcelMcp.CLI/Commands/ListActionsCommand.cs +++ b/src/PptMcp.CLI/Commands/ListActionsCommand.cs @@ -1,10 +1,10 @@ using System.ComponentModel; using System.Text.Json; -using Sbroenne.ExcelMcp.Service; -using Sbroenne.ExcelMcp.Generated; +using PptMcp.Service; +using PptMcp.Generated; using Spectre.Console.Cli; -namespace Sbroenne.ExcelMcp.CLI.Commands; +namespace PptMcp.CLI.Commands; /// <summary> /// Lists available actions for CLI commands. @@ -53,7 +53,7 @@ public override int Execute(CommandContext context, Settings settings, Cancellat { success = true, workflow = "REQUIRED: 1) session open/create <file> → get sessionId, 2) all commands need --session <id>, 3) session close --save to persist", - example = "session create file.xlsx → returns {sessionId:'abc'} → range set-values --session abc --range A1 --values 'Hello' → session close --save --session abc", + example = "session create file.pptx → returns {sessionId:'abc'} → range set-values --session abc --range A1 --values 'Hello' → session close --save --session abc", commands = all }; Console.WriteLine(JsonSerializer.Serialize(payload, ServiceProtocol.JsonOptions)); diff --git a/src/ExcelMcp.CLI/Commands/ServiceCommands.cs b/src/PptMcp.CLI/Commands/ServiceCommands.cs similarity index 92% rename from src/ExcelMcp.CLI/Commands/ServiceCommands.cs rename to src/PptMcp.CLI/Commands/ServiceCommands.cs index cf553097..0c4ec1c7 100644 --- a/src/ExcelMcp.CLI/Commands/ServiceCommands.cs +++ b/src/PptMcp.CLI/Commands/ServiceCommands.cs @@ -1,18 +1,18 @@ using System.Globalization; using System.Text.Json; -using Sbroenne.ExcelMcp.CLI.Infrastructure; -using Sbroenne.ExcelMcp.Service; +using PptMcp.CLI.Infrastructure; +using PptMcp.Service; using Spectre.Console.Cli; -namespace Sbroenne.ExcelMcp.CLI.Commands; +namespace PptMcp.CLI.Commands; // ============================================================================ // SERVICE LIFECYCLE COMMANDS // ============================================================================ /// <summary> -/// Starts the ExcelMCP CLI Service daemon if not already running. -/// Launches a background process running "excelcli service run". +/// Starts the PptMcp CLI Service daemon if not already running. +/// Launches a background process running "pptcli service run". /// </summary> internal sealed class ServiceStartCommand : AsyncCommand { @@ -33,7 +33,7 @@ public override async Task<int> ExecuteAsync(CommandContext context, Cancellatio } /// <summary> -/// Gracefully stops the ExcelMCP CLI Service daemon. +/// Gracefully stops the PptMcp CLI Service daemon. /// </summary> internal sealed class ServiceStopCommand : AsyncCommand { @@ -65,7 +65,7 @@ public override async Task<int> ExecuteAsync(CommandContext context, Cancellatio } /// <summary> -/// Shows ExcelMCP CLI Service status including PID, session count, and uptime. +/// Shows PptMcp CLI Service status including PID, session count, and uptime. /// Surfaces actual error details instead of silently masking connection failures. /// </summary> internal sealed class ServiceStatusCommand : AsyncCommand diff --git a/src/ExcelMcp.CLI/Commands/SessionCommands.cs b/src/PptMcp.CLI/Commands/SessionCommands.cs similarity index 96% rename from src/ExcelMcp.CLI/Commands/SessionCommands.cs rename to src/PptMcp.CLI/Commands/SessionCommands.cs index bc62f2e3..199589a6 100644 --- a/src/ExcelMcp.CLI/Commands/SessionCommands.cs +++ b/src/PptMcp.CLI/Commands/SessionCommands.cs @@ -1,11 +1,11 @@ using System.ComponentModel; using System.Text.Json; -using Sbroenne.ExcelMcp.CLI.Infrastructure; -using Sbroenne.ExcelMcp.Service; +using PptMcp.CLI.Infrastructure; +using PptMcp.Service; using Spectre.Console; using Spectre.Console.Cli; -namespace Sbroenne.ExcelMcp.CLI.Commands; +namespace PptMcp.CLI.Commands; // ============================================================================ // SESSION COMMANDS @@ -43,7 +43,7 @@ public override async Task<int> ExecuteAsync(CommandContext context, Settings se internal sealed class Settings : CommandSettings { [CommandArgument(0, "<FILE>")] - [Description("Path to the new Excel file to create")] + [Description("Path to the new PowerPoint file to create")] public string FilePath { get; init; } = string.Empty; [CommandOption("--timeout <SECONDS>")] @@ -84,7 +84,7 @@ public override async Task<int> ExecuteAsync(CommandContext context, Settings se internal sealed class Settings : CommandSettings { [CommandArgument(0, "<FILE>")] - [Description("Path to the Excel file to open")] + [Description("Path to the PowerPoint file to open")] public string FilePath { get; init; } = string.Empty; [CommandOption("--timeout <SECONDS>")] diff --git a/src/ExcelMcp.CLI/Infrastructure/CliServiceTray.cs b/src/PptMcp.CLI/Infrastructure/CliServiceTray.cs similarity index 91% rename from src/ExcelMcp.CLI/Infrastructure/CliServiceTray.cs rename to src/PptMcp.CLI/Infrastructure/CliServiceTray.cs index c6716253..2c3da0d2 100644 --- a/src/ExcelMcp.CLI/Infrastructure/CliServiceTray.cs +++ b/src/PptMcp.CLI/Infrastructure/CliServiceTray.cs @@ -1,13 +1,13 @@ using System.Reflection; using System.Runtime.InteropServices; -using Sbroenne.ExcelMcp.ComInterop.Session; +using PptMcp.ComInterop.Session; -namespace Sbroenne.ExcelMcp.CLI.Infrastructure; +namespace PptMcp.CLI.Infrastructure; /// <summary> -/// System tray icon for the ExcelMCP CLI daemon process. +/// System tray icon for the PptMcp CLI daemon process. /// Shows running sessions and allows closing them or stopping the service. -/// Ported from the old ExcelMcp.Service.ServiceTray with auto-update removed. +/// Ported from the old PptMcp.Service.ServiceTray with auto-update removed. /// </summary> internal sealed class CliServiceTray : IDisposable { @@ -30,7 +30,7 @@ public CliServiceTray(SessionManager sessionManager, Action requestShutdown) // Sessions submenu (Alt+S mnemonic) _sessionsMenu = new ToolStripMenuItem("&Sessions (0)"); - _sessionsMenu.AccessibleDescription = "Lists active Excel sessions"; + _sessionsMenu.AccessibleDescription = "Lists active PowerPoint sessions"; _sessionsMenu.DropDownItems.Add(new ToolStripMenuItem("No active sessions") { Enabled = false }); _contextMenu.Items.Add(_sessionsMenu); @@ -46,7 +46,7 @@ public CliServiceTray(SessionManager sessionManager, Action requestShutdown) // Exit (Alt+X mnemonic) var exitItem = new ToolStripMenuItem("E&xit"); - exitItem.AccessibleDescription = "Stop the ExcelMCP CLI service and exit"; + exitItem.AccessibleDescription = "Stop the PptMcp CLI service and exit"; exitItem.Click += (_, _) => ExitService(); _contextMenu.Items.Add(exitItem); @@ -56,7 +56,7 @@ public CliServiceTray(SessionManager sessionManager, Action requestShutdown) _notifyIcon = new NotifyIcon { Icon = icon, - Text = "ExcelMCP CLI Service", + Text = "PptMcp CLI Service", ContextMenuStrip = _contextMenu, Visible = true }; @@ -80,7 +80,7 @@ public CliServiceTray(SessionManager sessionManager, Action requestShutdown) private static Icon LoadEmbeddedIcon() { var assembly = Assembly.GetExecutingAssembly(); - var resourceName = "Sbroenne.ExcelMcp.CLI.Resources.excelcli.ico"; + var resourceName = "PptMcp.CLI.Resources.pptcli.ico"; using var stream = assembly.GetManifestResourceStream(resourceName); if (stream != null) @@ -112,8 +112,8 @@ private async void CheckForUpdateAsync() { ShowBalloon( "Update Available", - $"ExcelMCP CLI {latestVersion} is available (current: {currentVersion}).\n" + - "Run: dotnet tool update --global Sbroenne.ExcelMcp.CLI"); + $"PptMcp CLI {latestVersion} is available (current: {currentVersion}).\n" + + "Run: dotnet tool update --global PptMcp.CLI"); } } catch @@ -150,7 +150,7 @@ private void RefreshSessionsMenu() var fileName = Path.GetFileName(session.FilePath); var sessionMenu = new ToolStripMenuItem(fileName); sessionMenu.AccessibleName = $"Session: {fileName}"; - sessionMenu.AccessibleDescription = $"Excel session for {session.FilePath}"; + sessionMenu.AccessibleDescription = $"PowerPoint session for {session.FilePath}"; sessionMenu.ToolTipText = $"Session: {session.SessionId}\nPath: {session.FilePath}"; // Close session with save prompt @@ -171,8 +171,8 @@ private void RefreshSessionsMenu() } _notifyIcon.Text = sessions.Count > 0 - ? $"ExcelMCP CLI - {sessions.Count} session(s)" - : "ExcelMCP CLI Service"; + ? $"PptMcp CLI - {sessions.Count} session(s)" + : "PptMcp CLI Service"; } catch (Exception) { @@ -236,7 +236,7 @@ private void ShowSessions() var sessions = _sessionManager.GetActiveSessions(); if (sessions.Count == 0) { - ShowBalloon("ExcelMCP CLI Service", "No active sessions."); + ShowBalloon("PptMcp CLI Service", "No active sessions."); } else { @@ -270,7 +270,7 @@ private static async void ShowAbout() using var form = new Form { - Text = "About ExcelMCP CLI", + Text = "About PptMcp CLI", Size = new Size(420, updateAvailable ? 300 : 260), FormBorderStyle = FormBorderStyle.FixedDialog, StartPosition = FormStartPosition.CenterScreen, @@ -291,18 +291,18 @@ private static async void ShowAbout() Image = SystemIcons.Information.ToBitmap(), SizeMode = PictureBoxSizeMode.AutoSize, Location = new Point(20, 20), - AccessibleName = "ExcelMCP CLI icon", + AccessibleName = "PptMcp CLI icon", AccessibleRole = AccessibleRole.Graphic, TabStop = false }; var nameLabel = new Label { - Text = "ExcelMCP CLI Service", + Text = "PptMcp CLI Service", Font = new Font(Control.DefaultFont.FontFamily, 10, FontStyle.Bold), AutoSize = true, Location = new Point(70, 20), - AccessibleName = "ExcelMCP CLI Service", + AccessibleName = "PptMcp CLI Service", AccessibleRole = AccessibleRole.StaticText }; @@ -317,15 +317,15 @@ private static async void ShowAbout() var descLabel = new Label { - Text = "Excel automation for coding agents.", + Text = "PowerPoint automation for coding agents.", AutoSize = true, Location = new Point(70, 75), - AccessibleName = "Excel automation for coding agents", + AccessibleName = "PowerPoint automation for coding agents", AccessibleRole = AccessibleRole.StaticText }; - const string githubUrl = "https://github.com/sbroenne/mcp-server-excel"; - const string docsUrl = "https://excelmcpserver.dev/"; + const string githubUrl = "https://github.com/trsdn/mcp-server-ppt"; + const string docsUrl = "https://PptMcpserver.dev/"; var githubLabel = new Label { @@ -390,7 +390,7 @@ private static async void ShowAbout() var updateCmd = new TextBox { - Text = "dotnet tool update --global Sbroenne.ExcelMcp.CLI", + Text = "dotnet tool update --global PptMcp.CLI", ReadOnly = true, BorderStyle = BorderStyle.None, BackColor = form.BackColor, @@ -442,7 +442,7 @@ private void ExitService() var result = MessageBox.Show( $"There are {sessions.Count} active session(s).\n\n" + "Do you want to save all sessions before exiting?", - "Exit ExcelMCP CLI", + "Exit PptMcp CLI", MessageBoxButtons.YesNoCancel, MessageBoxIcon.Question); diff --git a/src/ExcelMcp.CLI/Infrastructure/DaemonAutoStart.cs b/src/PptMcp.CLI/Infrastructure/DaemonAutoStart.cs similarity index 95% rename from src/ExcelMcp.CLI/Infrastructure/DaemonAutoStart.cs rename to src/PptMcp.CLI/Infrastructure/DaemonAutoStart.cs index 506f1512..7c27457d 100644 --- a/src/ExcelMcp.CLI/Infrastructure/DaemonAutoStart.cs +++ b/src/PptMcp.CLI/Infrastructure/DaemonAutoStart.cs @@ -1,7 +1,7 @@ using System.Diagnostics; -using Sbroenne.ExcelMcp.Service; +using PptMcp.Service; -namespace Sbroenne.ExcelMcp.CLI.Infrastructure; +namespace PptMcp.CLI.Infrastructure; /// <summary> /// Ensures the CLI daemon is running before sending commands. @@ -13,7 +13,7 @@ internal static class DaemonAutoStart /// Gets the pipe name for the CLI daemon (supports env var override for testing). /// </summary> public static string GetPipeName() => - Environment.GetEnvironmentVariable("EXCELMCP_CLI_PIPE") ?? ServiceSecurity.GetCliPipeName(); + Environment.GetEnvironmentVariable("PptMcp_CLI_PIPE") ?? ServiceSecurity.GetCliPipeName(); /// <summary> /// Ensures the CLI daemon is running and returns a connected ServiceClient. @@ -89,7 +89,7 @@ private static bool IsDaemonMutexHeld(string pipeName) /// Used by both the daemon (to acquire) and the client (to detect a running daemon). /// </summary> internal static string GetDaemonMutexName(string pipeName) => - $"ExcelMcpCli_{pipeName}"; + $"PptMcpCli_{pipeName}"; private static async Task StartDaemonAsync(string pipeName, CancellationToken cancellationToken) { var exePath = Environment.ProcessPath; diff --git a/src/ExcelMcp.CLI/Infrastructure/NuGetVersionChecker.cs b/src/PptMcp.CLI/Infrastructure/NuGetVersionChecker.cs similarity index 94% rename from src/ExcelMcp.CLI/Infrastructure/NuGetVersionChecker.cs rename to src/PptMcp.CLI/Infrastructure/NuGetVersionChecker.cs index b647bb06..ff79ec08 100644 --- a/src/ExcelMcp.CLI/Infrastructure/NuGetVersionChecker.cs +++ b/src/PptMcp.CLI/Infrastructure/NuGetVersionChecker.cs @@ -1,14 +1,14 @@ using System.Net.Http.Json; using System.Text.Json.Serialization; -namespace Sbroenne.ExcelMcp.CLI.Infrastructure; +namespace PptMcp.CLI.Infrastructure; /// <summary> /// Checks NuGet for the latest version of the CLI package. /// </summary> internal static class NuGetVersionChecker { - private const string PackageId = "sbroenne.excelmcp.cli"; + private const string PackageId = "PptMcp.cli"; private const string NuGetIndexUrl = $"https://api.nuget.org/v3-flatcontainer/{PackageId}/index.json"; private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); diff --git a/src/ExcelMcp.CLI/Infrastructure/ServiceCommandBase.cs b/src/PptMcp.CLI/Infrastructure/ServiceCommandBase.cs similarity index 98% rename from src/ExcelMcp.CLI/Infrastructure/ServiceCommandBase.cs rename to src/PptMcp.CLI/Infrastructure/ServiceCommandBase.cs index 8ac889d1..7fe81b20 100644 --- a/src/ExcelMcp.CLI/Infrastructure/ServiceCommandBase.cs +++ b/src/PptMcp.CLI/Infrastructure/ServiceCommandBase.cs @@ -1,9 +1,9 @@ using System.Text.Json; -using Sbroenne.ExcelMcp.Service; +using PptMcp.Service; using Spectre.Console; using Spectre.Console.Cli; -namespace Sbroenne.ExcelMcp.CLI.Infrastructure; +namespace PptMcp.CLI.Infrastructure; /// <summary> /// Base class for CLI commands that send requests to the service. diff --git a/src/ExcelMcp.CLI/Infrastructure/VersionReporter.cs b/src/PptMcp.CLI/Infrastructure/VersionReporter.cs similarity index 82% rename from src/ExcelMcp.CLI/Infrastructure/VersionReporter.cs rename to src/PptMcp.CLI/Infrastructure/VersionReporter.cs index e942f728..575f5b66 100644 --- a/src/ExcelMcp.CLI/Infrastructure/VersionReporter.cs +++ b/src/PptMcp.CLI/Infrastructure/VersionReporter.cs @@ -1,7 +1,7 @@ using System.Reflection; using Spectre.Console; -namespace Sbroenne.ExcelMcp.CLI.Infrastructure; +namespace PptMcp.CLI.Infrastructure; internal static class VersionReporter { @@ -13,10 +13,10 @@ public static void WriteVersion() ?? version?.ToString() ?? "unknown"; - AnsiConsole.MarkupLine($"[bold cyan]ExcelMcp.CLI[/] [green]v{informational}[/]"); + AnsiConsole.MarkupLine($"[bold cyan]PptMcp.CLI[/] [green]v{informational}[/]"); AnsiConsole.MarkupLine($"[dim]Runtime:[/] {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}"); AnsiConsole.MarkupLine($"[dim]Platform:[/] {System.Runtime.InteropServices.RuntimeInformation.OSDescription}"); - AnsiConsole.MarkupLine("[bold]Repository:[/] https://github.com/sbroenne/mcp-server-excel"); + AnsiConsole.MarkupLine("[bold]Repository:[/] https://github.com/trsdn/mcp-server-ppt"); AnsiConsole.MarkupLine("[bold]License:[/] MIT"); } } diff --git a/src/ExcelMcp.CLI/ExcelMcp.CLI.csproj b/src/PptMcp.CLI/PptMcp.CLI.csproj similarity index 69% rename from src/ExcelMcp.CLI/ExcelMcp.CLI.csproj rename to src/PptMcp.CLI/PptMcp.CLI.csproj index 5b7018a4..0a0eb5ea 100644 --- a/src/ExcelMcp.CLI/ExcelMcp.CLI.csproj +++ b/src/PptMcp.CLI/PptMcp.CLI.csproj @@ -2,7 +2,7 @@ <PropertyGroup> <OutputType>Exe</OutputType> - <TargetFramework>net10.0-windows</TargetFramework> + <TargetFramework>net9.0-windows</TargetFramework> <UseWindowsForms>true</UseWindowsForms> <PublishAot>false</PublishAot> @@ -10,11 +10,11 @@ <GenerateTargetFrameworkAttribute>false</GenerateTargetFrameworkAttribute> <!-- Assembly and Namespace Configuration --> - <AssemblyName>excelcli</AssemblyName> - <RootNamespace>Sbroenne.ExcelMcp.CLI</RootNamespace> + <AssemblyName>pptcli</AssemblyName> + <RootNamespace>PptMcp.CLI</RootNamespace> <!-- Enable generated CLI Settings and Command classes --> - <DefineConstants>$(DefineConstants);SPECTRE_CONSOLE;EXCELCLI</DefineConstants> + <DefineConstants>$(DefineConstants);SPECTRE_CONSOLE;pptcli</DefineConstants> <!-- CLI doesn't need XML documentation warnings - command usage is shown in console output --> <NoWarn>$(NoWarn);CS1591</NoWarn> @@ -22,19 +22,19 @@ <!-- Version inherited from Directory.Build.props; overridden by CI via -p:Version --> <!-- Package-specific properties --> - <PackageId>Sbroenne.ExcelMcp.CLI</PackageId> - <Title>ExcelMcp CLI - Command-line interface tool for automating Microsoft Excel operations using COM interop by Sbroenne. 165 operations across Power Query M code, Power Pivot DAX measures, VBA macros, PivotTables, Excel Tables, ranges, formatting, data validation, and connections. Perfect for RPA, CI/CD pipelines, scripting (PowerShell/Bash), and batch processing. Windows x64 and ARM64 support. - excel;cli;powerquery;dax;power-pivot;automation;com;rpa;ci-cd;scripting;github-copilot;mcp;windows;dotnet-tool;sbroenne;excel-automation;vba;pivottables;excel-tables;batch-processing;devops + PptMcp.CLI + PptMcp CLI + Command-line interface tool for automating Microsoft PowerPoint operations using COM interop. Operations across slides, shapes, text, charts, and more. Perfect for RPA, CI/CD pipelines, scripting (PowerShell/Bash), and batch processing. Windows x64 and ARM64 support. + powerpoint;cli;automation;com;rpa;ci-cd;scripting;github-copilot;mcp;windows;dotnet-tool;slides;presentation;batch-processing;devops README.md - See https://github.com/sbroenne/mcp-server-excel/releases for release notes + See https://github.com/trsdn/mcp-server-ppt/releases for release notes false false true true - excelcli + pptcli true @@ -81,31 +81,31 @@ - + - - - + + + - - - + - + @@ -121,18 +121,18 @@ - + + Inputs="$(MSBuildProjectDirectory)\..\..\skills\templates\SKILL.cli.sbn;$(MSBuildProjectDirectory)\..\PptMcp.Core\obj\GeneratedFiles\PptMcp.Generators\PptMcp.Generators.ServiceRegistryGenerator\_SkillManifest.g.cs" + Outputs="$(MSBuildProjectDirectory)\..\..\skills\ppt-cli\SKILL.md"> $(MSBuildProjectDirectory)\..\..\skills\templates\SKILL.cli.sbn - $(MSBuildProjectDirectory)\..\..\skills\excel-cli\SKILL.md + $(MSBuildProjectDirectory)\..\..\skills\ppt-cli\SKILL.md - $(MSBuildProjectDirectory)\..\ExcelMcp.Core\obj\GeneratedFiles\ExcelMcp.Generators\Sbroenne.ExcelMcp.Generators.ServiceRegistryGenerator\_SkillManifest.g.cs + $(MSBuildProjectDirectory)\..\PptMcp.Core\obj\GeneratedFiles\PptMcp.Generators\PptMcp.Generators.ServiceRegistryGenerator\_SkillManifest.g.cs $(MSBuildProjectDirectory)\..\..\skills\shared - $(MSBuildProjectDirectory)\..\..\skills\excel-cli\references + $(MSBuildProjectDirectory)\..\..\skills\ppt-cli\references diff --git a/src/ExcelMcp.CLI/Program.cs b/src/PptMcp.CLI/Program.cs similarity index 88% rename from src/ExcelMcp.CLI/Program.cs rename to src/PptMcp.CLI/Program.cs index 1a3b308b..aff4613c 100644 --- a/src/ExcelMcp.CLI/Program.cs +++ b/src/PptMcp.CLI/Program.cs @@ -1,11 +1,11 @@ using System.Reflection; -using Sbroenne.ExcelMcp.CLI.Commands; -using Sbroenne.ExcelMcp.CLI.Generated; -using Sbroenne.ExcelMcp.CLI.Infrastructure; +using PptMcp.CLI.Commands; +using PptMcp.CLI.Generated; +using PptMcp.CLI.Infrastructure; using Spectre.Console; using Spectre.Console.Cli; -namespace Sbroenne.ExcelMcp.CLI; +namespace PptMcp.CLI; internal sealed class Program { @@ -67,7 +67,7 @@ private static async Task Main(string[] args) app.Configure(config => { - config.SetApplicationName("excelcli"); + config.SetApplicationName("pptcli"); config.SetApplicationVersion(GetCurrentVersion()); config.SetExceptionHandler((ex, _) => { @@ -79,9 +79,9 @@ private static async Task Main(string[] args) { branch.SetDescription("Service lifecycle management: start, stop, status."); branch.AddCommand("start") - .WithDescription("Start the ExcelMCP Service if not already running."); + .WithDescription("Start the PptMcp Service if not already running."); branch.AddCommand("stop") - .WithDescription("Gracefully stop the ExcelMCP Service."); + .WithDescription("Gracefully stop the PptMcp Service."); branch.AddCommand("status") .WithDescription("Show service status (running, PID, sessions, uptime)."); }); @@ -90,14 +90,26 @@ private static async Task Main(string[] args) config.AddCommand("batch") .WithDescription("Execute multiple commands from a JSON file or stdin. Outputs NDJSON (one result per line)."); + // Diagnostic commands — infrastructure validation (no PowerPoint required) + config.AddBranch("diag", branch => + { + branch.SetDescription("Diagnostic commands: ping, echo, validate-params."); + branch.AddCommand("ping") + .WithDescription("Ping the service to check connectivity."); + branch.AddCommand("echo") + .WithDescription("Echo back a message (tests parameter passing)."); + branch.AddCommand("validate-params") + .WithDescription("Validate parameter types and defaults."); + }); + // Session commands config.AddBranch("session", branch => { branch.SetDescription("Session management. WORKFLOW: open -> use sessionId -> close (--save to persist)."); branch.AddCommand("create") - .WithDescription("Create a new Excel file, open it, and create a session."); + .WithDescription("Create a new PowerPoint file, open it, and create a session."); branch.AddCommand("open") - .WithDescription("Open an Excel file and create a session."); + .WithDescription("Open a PowerPoint file and create a session."); branch.AddCommand("close") .WithDescription("Close a session. Use --save to persist changes."); branch.AddCommand("list") @@ -179,10 +191,8 @@ private static void RegisterOfficeAssemblyResolver() var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); string[] officeDirs = [ - Path.Combine(programFiles, @"Microsoft Office\root\Office16\ADDINS\PowerPivot Excel Add-inv16"), - Path.Combine(programFiles, @"Microsoft Office\root\Office16\ADDINS\PowerPivot Excel Add-in"), - Path.Combine(programFilesX86, @"Microsoft Office\root\Office16\ADDINS\PowerPivot Excel Add-inv16"), - Path.Combine(programFilesX86, @"Microsoft Office\root\Office16\ADDINS\PowerPivot Excel Add-in"), + Path.Combine(programFiles, @"Microsoft Office\root\Office16\ADDINS"), + Path.Combine(programFilesX86, @"Microsoft Office\root\Office16\ADDINS"), ]; foreach (var dir in officeDirs) { @@ -199,10 +209,10 @@ private static void RenderHeader() // Write banner to stderr so it never pollutes JSON output on stdout, // regardless of whether stdout is piped, redirected, or captured // (Console.IsOutputRedirected is false in VS Code integrated terminal - // even when capturing with $result = excelcli ...). + // even when capturing with $result = pptcli ...). var err = AnsiConsole.Create(new AnsiConsoleSettings { Out = new AnsiConsoleOutput(Console.Error) }); - err.Write(new FigletText("Excel CLI").Color(Spectre.Console.Color.Blue)); - err.MarkupLine("[dim]Excel automation powered by ExcelMcp Core[/]"); + err.Write(new FigletText("PPT CLI").Color(Spectre.Console.Color.Blue)); + err.MarkupLine("[dim]PowerPoint automation powered by PptMcp Core[/]"); err.MarkupLine("[yellow]Workflow:[/] [green]session open [/] → run commands with [green]--session [/] → [green]session close --save[/]."); err.MarkupLine("[dim]A background service manages sessions for performance.[/]"); err.WriteLine(); @@ -221,8 +231,8 @@ private static async Task HandleVersionAsync() if (updateAvailable) { AnsiConsole.MarkupLine($"[yellow]⚠ Update available:[/] [dim]{currentVersion}[/] → [green]{latestVersion}[/]"); - AnsiConsole.MarkupLine($"[cyan]Run:[/] [white]dotnet tool update --global Sbroenne.ExcelMcp.CLI[/]"); - AnsiConsole.MarkupLine($"[cyan]Release notes:[/] [blue]https://github.com/sbroenne/mcp-server-excel/releases/latest[/]"); + AnsiConsole.MarkupLine($"[cyan]Run:[/] [white]dotnet tool update --global PptMcp.CLI[/]"); + AnsiConsole.MarkupLine($"[cyan]Release notes:[/] [blue]https://github.com/trsdn/mcp-server-ppt/releases/latest[/]"); } else if (latestVersion != null) { @@ -275,7 +285,7 @@ private static int RunServiceDaemon(string? pipeNameOverride = null) daemonMutex.Dispose(); return 0; } - var service = new Service.ExcelMcpService(); + var service = new Service.PptMcpService(); // Capture the UI synchronization context after Application starts SynchronizationContext? uiContext = null; diff --git a/src/ExcelMcp.CLI/README.md b/src/PptMcp.CLI/README.md similarity index 50% rename from src/ExcelMcp.CLI/README.md rename to src/PptMcp.CLI/README.md index 112c7942..28d427f3 100644 --- a/src/ExcelMcp.CLI/README.md +++ b/src/PptMcp.CLI/README.md @@ -1,23 +1,23 @@ -# ExcelMcp.CLI - Command-Line Interface for Excel Automation +# PptMcp.CLI - Command-Line Interface for PowerPoint Automation -[![NuGet](https://img.shields.io/nuget/v/Sbroenne.ExcelMcp.CLI.svg)](https://www.nuget.org/packages/Sbroenne.ExcelMcp.CLI) -[![Downloads](https://img.shields.io/nuget/dt/Sbroenne.ExcelMcp.CLI.svg)](https://www.nuget.org/packages/Sbroenne.ExcelMcp.CLI) +[![NuGet](https://img.shields.io/nuget/v/PptMcp.CLI.svg)](https://www.nuget.org/packages/PptMcp.CLI) +[![Downloads](https://img.shields.io/nuget/dt/PptMcp.CLI.svg)](https://www.nuget.org/packages/PptMcp.CLI) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -**Command-line interface for Excel automation — preferred by coding agents.** +**Command-line interface for PowerPoint automation — preferred by coding agents.** -> **Published as its own .NET tool** - Install `Sbroenne.ExcelMcp.CLI` to get the `excelcli` command. Install `Sbroenne.ExcelMcp.McpServer` separately when you also need the MCP server (`mcp-excel`). +> **Published as its own .NET tool** - Install `PptMcp.CLI` to get the `pptcli` command. Install `PptMcp.McpServer` separately when you also need the MCP server (`mcp-ppt`). The CLI provides 17 command categories with 225 operations matching the MCP Server. Uses **64% fewer tokens** than MCP Server because it wraps all operations in a single tool with skill-based guidance instead of loading 25 tool schemas into context. | Interface | Best For | Why | |-----------|----------|-----| -| **CLI** (`excelcli`) | Coding agents (Copilot, Cursor, Windsurf) | **64% fewer tokens** - single tool, no large schemas | +| **CLI** (`pptcli`) | Coding agents (Copilot, Cursor, Windsurf) | **64% fewer tokens** - single tool, no large schemas | | **MCP Server** | Conversational AI (Claude Desktop, VS Code Chat) | Rich tool discovery, persistent connection | Also perfect for RPA workflows, CI/CD pipelines, batch processing, and automated testing. -➡️ **[Learn more and see examples](https://sbroenne.github.io/mcp-server-excel/)** +➡️ **[Learn more and see examples](https://trsdn.github.io/mcp-server-ppt/)** --- @@ -27,31 +27,31 @@ Also perfect for RPA workflows, CI/CD pipelines, batch processing, and automated ```powershell # Install CLI tool -dotnet tool install --global Sbroenne.ExcelMcp.CLI +dotnet tool install --global PptMcp.CLI # Verify installation -excelcli --version +pptcli --version # Get help -excelcli --help +pptcli --help ``` -> 🔁 **Session Workflow:** Always start with `excelcli session open ` (captures the session id), pass `--session ` to other commands, then `excelcli session close --save` when finished. The CLI reuses the same Excel instance through that lifecycle. +> 🔁 **Session Workflow:** Always start with `pptcli session open ` (captures the session id), pass `--session ` to other commands, then `pptcli session close --save` when finished. The CLI reuses the same PowerPoint instance through that lifecycle. ### Check for Updates ```powershell # Check if newer version is available -excelcli version --check +pptcli version --check # Update if available -dotnet tool update --global Sbroenne.ExcelMcp.CLI +dotnet tool update --global PptMcp.CLI ``` ### Uninstall ```powershell -dotnet tool uninstall --global Sbroenne.ExcelMcp.CLI +dotnet tool uninstall --global PptMcp.CLI ``` ## 🤫 Quiet Mode (Agent-Friendly) @@ -59,18 +59,18 @@ dotnet tool uninstall --global Sbroenne.ExcelMcp.CLI For scripting and coding agents, use `-q`/`--quiet` to suppress banner and output JSON only: ```powershell -excelcli -q session open data.xlsx -excelcli -q range get-values --session 1 --sheet Sheet1 --range A1:B2 -excelcli -q session close --session 1 --save +pptcli -q session open data.pptx +pptcli -q range get-values --session 1 --sheet Sheet1 --range A1:B2 +pptcli -q session close --session 1 --save ``` Banner auto-suppresses when stdout is piped or redirected. ## 🆘 Built-in Help -- `excelcli --help` – lists every command category plus the new descriptions from `Program.cs` -- `excelcli --help` – shows verb-specific arguments (for example `excelcli sheet --help`) -- `excelcli session --help` – displays nested verbs such as `open`, `save`, `close`, and `list` +- `pptcli --help` – lists every command category plus the new descriptions from `Program.cs` +- `pptcli --help` – shows verb-specific arguments (for example `pptcli sheet --help`) +- `pptcli session --help` – displays nested verbs such as `open`, `save`, `close`, and `list` Descriptions are kept in sync with the CLI source so the help output always reflects the latest capabilities. @@ -78,7 +78,7 @@ Descriptions are kept in sync with the CLI source so the help output always refl ## ✨ Key Features -### 🔧 Excel Development Automation +### 🔧 PowerPoint Development Automation - **Power Query Management** - Export, import, update, and version control M code - **VBA Development** - Manage VBA modules, run macros, automated testing - **Data Model & DAX** - Create measures, manage relationships, Power Pivot operations @@ -86,22 +86,22 @@ Descriptions are kept in sync with the CLI source so the help output always refl - **Conditional Formatting** - Add rules (cell value, expression-based), clear formatting ### 📊 Data Operations -- **Worksheet Management** - Create, rename, copy, delete sheets with tab colors and visibility +- **Slide Management** - Create, rename, copy, delete sheets with tab colors and visibility - **Range Operations** - Read/write values, formulas, formatting, validation -- **Excel Tables** - Lifecycle management, filtering, sorting, structured references +- **PowerPoint Tables** - Lifecycle management, filtering, sorting, structured references - **Connection Management** - OLEDB, ODBC, Text, Web connections with testing ### 🛡️ Production Ready -- **Zero Corruption Risk** - Uses Excel's native COM API (not file manipulation) +- **Zero Corruption Risk** - Uses PowerPoint's native COM API (not file manipulation) - **Error Handling** - Comprehensive validation and helpful error messages - **CI/CD Integration** - Perfect for automated workflows and testing -- **Windows Native** - Optimized for Windows Excel automation +- **Windows Native** - Optimized for Windows PowerPoint automation --- ## 📋 Command Categories -ExcelMcp.CLI provides **225 operations** across 17 command categories: +PptMcp.CLI provides **225 operations** across 17 command categories: 📚 **[Complete Feature Reference →](../../FEATURES.md)** - Full documentation with all operations @@ -114,7 +114,7 @@ ExcelMcp.CLI provides **225 operations** across 17 command categories: | **Power Query** | 10 | `powerquery list`, `powerquery create`, `powerquery refresh`, `powerquery update` | | **Ranges** | 42 | `range get-values`, `range set-values`, `range copy`, `range find`, `range merge-cells` | | **Conditional Formatting** | 2 | `conditionalformat add-rule`, `conditionalformat clear-rules` | -| **Excel Tables** | 27 | `table create`, `table apply-filter`, `table get-data`, `table sort`, `table add-column` | +| **PowerPoint Tables** | 27 | `table create`, `table apply-filter`, `table get-data`, `table sort`, `table add-column` | | **Charts** | 14 | `chart create-from-range`, `chart list`, `chart delete`, `chart move`, `chart fit-to-range` | | **Chart Config** | 14 | `chartconfig set-title`, `chartconfig add-series`, `chartconfig set-style`, `chartconfig data-labels` | | **PivotTables** | 30 | `pivottable create-from-range`, `pivottable add-row-field`, `pivottable refresh` | @@ -136,36 +136,36 @@ The CLI uses an explicit session-based workflow where you open a file, perform o ```powershell # 1. Open a session -excelcli session open data.xlsx +pptcli session open data.pptx # Output: Session ID: 550e8400-e29b-41d4-a716-446655440000 # 2. List active sessions anytime -excelcli session list +pptcli session list # 3. Use the session ID with any commands -excelcli sheet create --session 550e8400-e29b-41d4-a716-446655440000 --sheet "NewSheet" -excelcli powerquery list --session 550e8400-e29b-41d4-a716-446655440000 +pptcli sheet create --session 550e8400-e29b-41d4-a716-446655440000 --sheet "NewSheet" +pptcli powerquery list --session 550e8400-e29b-41d4-a716-446655440000 # 4. Close and save changes -excelcli session close 550e8400-e29b-41d4-a716-446655440000 --save +pptcli session close 550e8400-e29b-41d4-a716-446655440000 --save # OR: Close and discard changes (no --save flag) -excelcli session close 550e8400-e29b-41d4-a716-446655440000 +pptcli session close 550e8400-e29b-41d4-a716-446655440000 ``` ### Session Lifecycle Benefits - **Explicit control** - Know exactly when changes are persisted with `--save` -- **Batch efficiency** - Keep single Excel instance open for multiple operations (75-90% faster) +- **Batch efficiency** - Keep single PowerPoint instance open for multiple operations (75-90% faster) - **Flexibility** - Save and close in one command, or close without saving -- **Clean resource management** - Automatic Excel cleanup when session closes +- **Clean resource management** - Automatic PowerPoint cleanup when session closes ### Background Service & System Tray -When you run your first CLI command, the **ExcelMCP Service** starts automatically in the background. The service: +When you run your first CLI command, the **PptMcp Service** starts automatically in the background. The service: -- **Manages Excel COM** - Keeps Excel instance alive between commands (no restart overhead) -- **Shows system tray icon** - Look for the Excel icon in your Windows taskbar notification area +- **Manages PowerPoint COM** - Keeps PowerPoint instance alive between commands (no restart overhead) +- **Shows system tray icon** - Look for the PowerPoint icon in your Windows taskbar notification area - **Tracks sessions** - Right-click the tray icon to see active sessions and close them - **Shows session origin** - Sessions are labeled [CLI] or [MCP] showing which client created them - **Auto-updates** - Notifies you when a new version is available and allows one-click updates @@ -183,46 +183,46 @@ The service auto-stops after 10 minutes of inactivity (no active sessions). ## 💡 Command Reference -**Use `excelcli --help` for complete parameter documentation.** The CLI help is always in sync with the code. +**Use `pptcli --help` for complete parameter documentation.** The CLI help is always in sync with the code. ```powershell -excelcli --help # List all commands -excelcli session --help # Session lifecycle (open, close, save, list) -excelcli powerquery --help # Power Query operations -excelcli range --help # Cell/range operations -excelcli table --help # Excel Table operations -excelcli pivottable --help # PivotTable operations -excelcli datamodel --help # Data Model & DAX -excelcli vba --help # VBA module management +pptcli --help # List all commands +pptcli session --help # Session lifecycle (open, close, save, list) +pptcli powerquery --help # Power Query operations +pptcli range --help # Cell/range operations +pptcli table --help # PowerPoint Table operations +pptcli pivottable --help # PivotTable operations +pptcli datamodel --help # Data Model & DAX +pptcli vba --help # VBA module management ``` ### Typical Workflows **Session-based automation (recommended):** ```powershell -excelcli -q session open report.xlsx # Returns session ID -excelcli -q sheet create --session 1 --sheet "Summary" -excelcli -q range set-values --session 1 --sheet Summary --range A1 --values '[["Hello"]]' -excelcli -q session close --session 1 --save # Persist changes +pptcli -q session open report.pptx # Returns session ID +pptcli -q sheet create --session 1 --sheet "Summary" +pptcli -q range set-values --session 1 --sheet Summary --range A1 --values '[["Hello"]]' +pptcli -q session close --session 1 --save # Persist changes ``` **Power Query ETL:** ```powershell -excelcli powerquery create --session 1 --query "CleanData" --mcode-file transform.pq -excelcli powerquery refresh --session 1 --query "CleanData" +pptcli powerquery create --session 1 --query "CleanData" --mcode-file transform.pq +pptcli powerquery refresh --session 1 --query "CleanData" ``` **PivotTable from Data Model:** ```powershell -excelcli pivottable create-from-datamodel --session 1 --table Sales --dest-sheet Analysis --dest-cell A1 --pivot-table SalesPivot -excelcli pivottable add-row-field --session 1 --pivot-table SalesPivot --field Region -excelcli pivottable add-value-field --session 1 --pivot-table SalesPivot --field Amount --function Sum +pptcli pivottable create-from-datamodel --session 1 --table Sales --dest-sheet Analysis --dest-cell A1 --pivot-table SalesPivot +pptcli pivottable add-row-field --session 1 --pivot-table SalesPivot --field Region +pptcli pivottable add-value-field --session 1 --pivot-table SalesPivot --field Amount --function Sum ``` **VBA automation:** ```powershell -excelcli vba import --session 1 --module "Helpers" --code-file helpers.vba -excelcli vba run --session 1 --macro "Helpers.ProcessData" +pptcli vba import --session 1 --module "Helpers" --code-file helpers.vba +pptcli vba run --session 1 --macro "Helpers.ProcessData" ``` --- @@ -232,10 +232,10 @@ excelcli vba run --session 1 --macro "Helpers.ProcessData" | Requirement | Details | Why Required | |-------------|---------|--------------| | **Windows OS** | Windows 10/11 or Server 2016+ | COM interop is Windows-specific | -| **Microsoft Excel** | Excel 2016 or later | CLI controls actual Excel application | +| **Microsoft PowerPoint** | PowerPoint 2016 or later | CLI controls actual PowerPoint application | | **.NET 10 Runtime** | [Download](https://dotnet.microsoft.com/download/dotnet/10.0) | Required to run .NET global tools | -> **Note:** ExcelMcp.CLI controls the actual Excel application via COM interop, not just file formats. This provides access to Power Query, VBA runtime, formula engine, and all Excel features, but requires Excel to be installed. +> **Note:** PptMcp.CLI controls the actual PowerPoint application via COM interop, not just file formats. This provides access to all PowerPoint features, but requires PowerPoint to be installed. --- @@ -243,31 +243,31 @@ excelcli vba run --session 1 --macro "Helpers.ProcessData" VBA commands require **"Trust access to the VBA project object model"** to be enabled: -1. Open Excel +1. Open PowerPoint 2. Go to **File → Options → Trust Center** 3. Click **"Trust Center Settings"** 4. Select **"Macro Settings"** 5. Check **"✓ Trust access to the VBA project object model"** 6. Click **OK** twice -This is a security setting that must be manually enabled. ExcelMcp.CLI never modifies security settings automatically. +This is a security setting that must be manually enabled. PptMcp.CLI never modifies security settings automatically. -For macro-enabled workbooks, use `.xlsm` extension: +For macro-enabled presentations, use `.pptm` extension: ```powershell -excelcli session create macros.xlsm +pptcli session create macros.pptm # Returns session ID (e.g., 1) -excelcli vba import --session 1 --module MyModule --code-file code.vba -excelcli session close --session 1 --save +pptcli vba import --session 1 --module MyModule --code-file code.vba +pptcli session close --session 1 --save ``` --- ## 📖 Complete Documentation -- **[NuGet Package](https://www.nuget.org/packages/Sbroenne.ExcelMcp.CLI)** - .NET Global Tool installation -- **[GitHub Repository](https://github.com/sbroenne/mcp-server-excel)** - Source code and issues -- **[Release Notes](https://github.com/sbroenne/mcp-server-excel/releases)** - Latest updates +- **[NuGet Package](https://www.nuget.org/packages/PptMcp.CLI)** - .NET Global Tool installation +- **[GitHub Repository](https://github.com/trsdn/mcp-server-ppt)** - Source code and issues +- **[Release Notes](https://github.com/trsdn/mcp-server-ppt/releases)** - Latest updates --- @@ -279,15 +279,15 @@ excelcli session close --session 1 --save # Verify .NET tools path is in your PATH environment variable dotnet tool list --global -# If excelcli is listed but not found, add .NET tools to PATH: +# If pptcli is listed but not found, add .NET tools to PATH: # The default location is: %USERPROFILE%\.dotnet\tools ``` -### Excel Not Found +### PowerPoint Not Found ```powershell -# Error: "Microsoft Excel is not installed" -# Solution: Install Microsoft Excel (any version 2016+) +# Error: "Microsoft PowerPoint is not installed" +# Solution: Install Microsoft PowerPoint (any version 2016+) ``` ### VBA Access Denied @@ -301,7 +301,7 @@ dotnet tool list --global ```powershell # Run PowerShell/CMD as Administrator if you encounter permission errors -# Or install to user directory: dotnet tool install --global Sbroenne.ExcelMcp.CLI +# Or install to user directory: dotnet tool install --global PptMcp.CLI ``` --- @@ -312,12 +312,12 @@ dotnet tool list --global ```powershell # PowerShell script example -$files = Get-ChildItem *.xlsx +$files = Get-ChildItem *.pptx foreach ($file in $files) { - $session = excelcli session open $file.Name | Select-String "Session ID: (.+)" | ForEach-Object { $_.Matches.Groups[1].Value } - excelcli powerquery refresh --session $session --query "Sales Data" - excelcli datamodel refresh --session $session - excelcli session close $session --save + $session = pptcli session open $file.Name | Select-String "Session ID: (.+)" | ForEach-Object { $_.Matches.Groups[1].Value } + pptcli powerquery refresh --session $session --query "Sales Data" + pptcli datamodel refresh --session $session + pptcli session close $session --save } ``` @@ -325,34 +325,34 @@ foreach ($file in $files) { ```yaml # GitHub Actions example -- name: Install ExcelMcp.CLI - run: dotnet tool install --global Sbroenne.ExcelMcp.CLI +- name: Install PptMcp.CLI + run: dotnet tool install --global PptMcp.CLI -- name: Process Excel Files +- name: Process PowerPoint Files run: | - SESSION=$(excelcli session open data.xlsx | grep "Session ID:" | cut -d' ' -f3) - excelcli powerquery create --session $SESSION --query "Query1" --mcode-file queries/query1.pq - excelcli powerquery refresh --session $SESSION --query "Query1" - excelcli session close $SESSION --save + SESSION=$(pptcli session open data.pptx | grep "Session ID:" | cut -d' ' -f3) + pptcli powerquery create --session $SESSION --query "Query1" --mcode-file queries/query1.pq + pptcli powerquery refresh --session $SESSION --query "Query1" + pptcli session close $SESSION --save ``` ## ✅ Tested Scenarios -The CLI ships with real Excel-backed integration tests that exercise the session lifecycle plus worksheet creation/listing flows through the same commands you run locally. Execute them with: +The CLI ships with real PowerPoint-backed integration tests that exercise the session lifecycle plus slide creation/listing flows through the same commands you run locally. Execute them with: ```powershell -dotnet test tests/ExcelMcp.CLI.Tests/ExcelMcp.CLI.Tests.csproj --filter "Layer=CLI" +dotnet test tests/PptMcp.CLI.Tests/PptMcp.CLI.Tests.csproj --filter "Layer=CLI" ``` -These tests open actual workbooks, issue `session open/list/close`, and call `excelcli sheet` actions to ensure the command pipeline stays healthy. +These tests open actual presentations, issue `session open/list/close`, and call `pptcli sheet` actions to ensure the command pipeline stays healthy. --- ## 🤝 Related Tools -- **[ExcelMcp.McpServer](https://www.nuget.org/packages/Sbroenne.ExcelMcp.McpServer)** - MCP server for AI assistant integration -- **[Excel MCP VS Code Extension](https://marketplace.visualstudio.com/items?itemName=sbroenne.excel-mcp)** - One-click Excel automation in VS Code +- **[PptMcp.McpServer](https://www.nuget.org/packages/PptMcp.McpServer)** - MCP server for AI assistant integration +- **[PowerPoint MCP VS Code Extension](https://marketplace.visualstudio.com/items?itemName=trsdn.ppt-mcp)** - One-click PowerPoint automation in VS Code --- @@ -365,10 +365,10 @@ MIT License - see [LICENSE](../../LICENSE) for details. ## 🙋 Support -- **Issues**: [GitHub Issues](https://github.com/sbroenne/mcp-server-excel/issues) -- **Discussions**: [GitHub Discussions](https://github.com/sbroenne/mcp-server-excel/discussions) +- **Issues**: [GitHub Issues](https://github.com/trsdn/mcp-server-ppt/issues) +- **Discussions**: [GitHub Discussions](https://github.com/trsdn/mcp-server-ppt/discussions) - **Documentation**: [Complete Docs](../../docs/) --- -**Built with ❤️ for Excel developers and automation engineers** +**Built with ❤️ for PowerPoint developers and automation engineers** diff --git a/gh-pages/favicon.ico b/src/PptMcp.CLI/Resources/pptcli.ico similarity index 100% rename from gh-pages/favicon.ico rename to src/PptMcp.CLI/Resources/pptcli.ico diff --git a/src/PptMcp.ComInterop/ComInteropConstants.cs b/src/PptMcp.ComInterop/ComInteropConstants.cs new file mode 100644 index 00000000..2af1ef2c --- /dev/null +++ b/src/PptMcp.ComInterop/ComInteropConstants.cs @@ -0,0 +1,80 @@ +namespace PptMcp.ComInterop; + +/// +/// Constants for PowerPoint COM interop operations. +/// +public static class ComInteropConstants +{ + #region Timeouts + + /// + /// Timeout for PowerPoint.Quit() operation (30 seconds). + /// With DisplayAlerts=false, PowerPoint quits quickly. This timeout catches hung scenarios. + /// + public static readonly TimeSpan PowerPointQuitTimeout = TimeSpan.FromSeconds(30); + + /// + /// Timeout for STA thread join after quit. + /// CRITICAL: Must be >= PowerPointQuitTimeout to ensure Dispose() waits for CloseAndQuit() to complete. + /// Set to PowerPointQuitTimeout + 15s margin for presentation close and COM cleanup. + /// + public static readonly TimeSpan StaThreadJoinTimeout = PowerPointQuitTimeout + TimeSpan.FromSeconds(15); + + /// + /// Timeout for save operations (5 minutes). + /// Large presentations may take longer to save. + /// + public static readonly TimeSpan SaveOperationTimeout = TimeSpan.FromMinutes(5); + + /// + /// Default timeout for individual PowerPoint operations (5 minutes). + /// Most operations complete in under 30 seconds, but this provides buffer for slow machines. + /// Can be overridden when creating a session via timeoutSeconds parameter. + /// + public static readonly TimeSpan DefaultOperationTimeout = TimeSpan.FromMinutes(5); + + /// + /// Maximum wait time for session creation file lock acquisition (5 seconds). + /// + public static readonly TimeSpan SessionFileLockTimeout = TimeSpan.FromSeconds(5); + + #endregion + + #region Sleep Intervals + + /// + /// Delay between file lock acquisition retries (100ms). + /// + public const int FileLockRetryDelayMs = 100; + + /// + /// Delay between session lock acquisition retries (200ms). + /// + public const int SessionLockRetryDelayMs = 200; + + #endregion + + #region PowerPoint File Formats + + /// + /// PowerPoint Open XML Presentation format code (.pptx). + /// PpSaveAsFileType.ppSaveAsOpenXMLPresentation = 24 + /// + public const int PpSaveAsOpenXMLPresentation = 24; + + /// + /// PowerPoint Open XML Macro-Enabled Presentation format code (.pptm). + /// PpSaveAsFileType.ppSaveAsOpenXMLPresentationMacroEnabled = 25 + /// + public const int PpSaveAsOpenXMLPresentationMacroEnabled = 25; + + /// + /// PowerPoint default format code. + /// PpSaveAsFileType.ppSaveAsDefault = 11 + /// + public const int PpSaveAsDefault = 11; + + #endregion +} + + diff --git a/src/PptMcp.ComInterop/ComUtilities.cs b/src/PptMcp.ComInterop/ComUtilities.cs new file mode 100644 index 00000000..dcdc65a8 --- /dev/null +++ b/src/PptMcp.ComInterop/ComUtilities.cs @@ -0,0 +1,135 @@ +using System.Runtime.InteropServices; +using PowerPoint = Microsoft.Office.Interop.PowerPoint; + +namespace PptMcp.ComInterop; + +/// +/// Low-level COM interop utilities for PowerPoint automation. +/// Provides helpers for managing COM object lifecycle. +/// +public static class ComUtilities +{ + /// + /// Safely releases a COM object and sets the reference to null + /// + /// The COM object to release + /// + /// Use this helper to release intermediate COM objects (like slides, shapes) + /// to prevent PowerPoint process from staying open. This is especially important when + /// iterating through collections or accessing multiple COM properties. + /// + /// + /// + /// dynamic? slides = null; + /// try + /// { + /// slides = presentation.Slides; + /// // Use slides... + /// } + /// finally + /// { + /// ComUtilities.Release(ref slides); + /// } + /// + /// + public static void Release(ref T? comObject) where T : class + { + if (comObject != null) + { + try + { + Marshal.ReleaseComObject(comObject); + } + catch (Exception) + { + // Ignore errors during release — COM object may already be released or RPC disconnected + } + comObject = null; + } + } + + /// + /// Safely attempts to quit a PowerPoint application COM object. + /// This is a fire-and-forget cleanup helper - errors are swallowed. + /// + /// The PowerPoint.Application COM object + /// + /// Use this for cleanup scenarios where you want to quit PowerPoint but don't + /// need to handle or report errors. For production shutdown with retry + /// logic, use PptShutdownService.CloseAndQuit instead. + /// + public static void TryQuitPowerPoint(PowerPoint.Application? powerPoint) + { + if (powerPoint == null) return; + + try + { + powerPoint.Quit(); + } + catch (Exception) + { + // Swallow errors during cleanup — PowerPoint may already be gone + } + } + + /// + /// Safely gets a string property from a COM object, returning empty string if null + /// + /// COM object + /// Property name + /// Property value or empty string + public static string SafeGetString(dynamic? obj, string propertyName) + { + try + { + var value = propertyName switch + { + "Name" => obj.Name, + "Description" => obj.Description, + _ => null + }; + return value?.ToString() ?? string.Empty; + } + catch (Exception) + { + return string.Empty; + } + } + + /// + /// Safely gets an integer property from a COM object, returning 0 if null or invalid + /// + /// COM object + /// Property name + /// Property value or 0 + public static int SafeGetInt(dynamic? obj, string propertyName) + { + try + { + var value = propertyName switch + { + "Count" => obj.Count, + _ => 0 + }; + return Convert.ToInt32(value); + } + catch (Exception) + { + return 0; + } + } + + [DllImport("kernel32.dll")] + private static extern void Sleep(uint dwMilliseconds); + + /// + /// Kernel-level sleep that does NOT pump the STA COM message queue. + /// Unlike Thread.Sleep (which uses CoWaitForMultipleHandles internally and wakes early on + /// every incoming COM event), this calls Win32 Sleep() directly via NtDelayExecution — + /// the thread genuinely sleeps for the full interval regardless of COM callbacks. + /// + public static void KernelSleep(int milliseconds) => + Sleep((uint)Math.Max(0, milliseconds)); +} + + diff --git a/src/ExcelMcp.ComInterop/FileAccessValidator.cs b/src/PptMcp.ComInterop/FileAccessValidator.cs similarity index 88% rename from src/ExcelMcp.ComInterop/FileAccessValidator.cs rename to src/PptMcp.ComInterop/FileAccessValidator.cs index ebddcabb..86668b80 100644 --- a/src/ExcelMcp.ComInterop/FileAccessValidator.cs +++ b/src/PptMcp.ComInterop/FileAccessValidator.cs @@ -1,25 +1,25 @@ -namespace Sbroenne.ExcelMcp.ComInterop; +namespace PptMcp.ComInterop; /// /// Utility class for validating file access and locking status. -/// Provides OS-level file lock detection and IRM/AIP-encryption detection before Excel COM operations. +/// Provides OS-level file lock detection and IRM/AIP-encryption detection before PowerPoint COM operations. /// public static class FileAccessValidator { // OLE2 Compound Document Format signature. - // IRM/AIP-protected Excel files are stored as OLE2 containers with an EncryptedPackage + // IRM/AIP-protected PowerPoint files are stored as OLE2 containers with an EncryptedPackage // stream instead of the standard ZIP-based Office Open XML format. private static ReadOnlySpan Ole2Signature => [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; /// /// Detects if the file is IRM/AIP-protected by checking for the OLE2 compound document - /// signature. IRM-protected files must be opened as read-only with Excel visible so the + /// signature. IRM-protected files must be opened as read-only with PowerPoint visible so the /// user can authenticate through the Information Rights Management credential prompt. /// /// The file path to inspect. /// /// true if the file has the OLE2 Compound Document header, indicating IRM/AIP - /// encryption; false for standard ZIP-based .xlsx/.xlsm files or if the file + /// encryption; false for standard ZIP-based .pptx/.pptm files or if the file /// cannot be read. /// public static bool IsIrmProtected(string filePath) @@ -45,7 +45,7 @@ public static bool IsIrmProtected(string filePath) /// /// Validates that a file is not locked by attempting to open it with exclusive access. /// Throws InvalidOperationException if file is locked or inaccessible. - /// This is a fast OS-level check that doesn't require launching Excel. + /// This is a fast OS-level check that doesn't require launching PowerPoint. /// /// The file path to validate /// Thrown when file is locked or inaccessible @@ -62,7 +62,7 @@ public static void ValidateFileNotLocked(string filePath) } catch (IOException ioEx) { - // File is locked by another process (most likely already open in Excel) + // File is locked by another process (most likely already open in PowerPoint) throw CreateFileLockedError(filePath, ioEx); } catch (UnauthorizedAccessException uaEx) @@ -87,9 +87,9 @@ public static InvalidOperationException CreateFileLockedError(string filePath, E { return new InvalidOperationException( $"Cannot open '{Path.GetFileName(filePath)}'. " + - "The file is already open in Excel or another process is using it. " + + "The file is already open in PowerPoint or another process is using it. " + "Please close the file before running automation commands. " + - "ExcelMcp requires exclusive access to workbooks during operations.", + "PptMcp requires exclusive access to presentations during operations.", innerException); } } diff --git a/src/ExcelMcp.ComInterop/IOleMessageFilter.cs b/src/PptMcp.ComInterop/IOleMessageFilter.cs similarity index 89% rename from src/ExcelMcp.ComInterop/IOleMessageFilter.cs rename to src/PptMcp.ComInterop/IOleMessageFilter.cs index b1dfaebc..e61e50ec 100644 --- a/src/ExcelMcp.ComInterop/IOleMessageFilter.cs +++ b/src/PptMcp.ComInterop/IOleMessageFilter.cs @@ -1,11 +1,11 @@ using System.Runtime.InteropServices; using System.Runtime.InteropServices.Marshalling; -namespace Sbroenne.ExcelMcp.ComInterop; +namespace PptMcp.ComInterop; /// /// COM interface for handling incoming and outgoing COM calls. -/// Used to intercept Excel busy/retry scenarios. +/// Used to intercept _pptApp busy/retry scenarios. /// [GeneratedComInterface] [Guid("00000016-0000-0000-C000-000000000046")] diff --git a/src/ExcelMcp.ComInterop/OleMessageFilter.cs b/src/PptMcp.ComInterop/OleMessageFilter.cs similarity index 90% rename from src/ExcelMcp.ComInterop/OleMessageFilter.cs rename to src/PptMcp.ComInterop/OleMessageFilter.cs index 3be0f1f1..5a8de265 100644 --- a/src/ExcelMcp.ComInterop/OleMessageFilter.cs +++ b/src/PptMcp.ComInterop/OleMessageFilter.cs @@ -1,15 +1,15 @@ using System.Runtime.InteropServices; using System.Runtime.InteropServices.Marshalling; -namespace Sbroenne.ExcelMcp.ComInterop; +namespace PptMcp.ComInterop; /// -/// OLE Message Filter for handling Excel COM busy/retry scenarios. -/// Automatically retries when Excel returns RPC_E_SERVERCALL_RETRYLATER. +/// OLE Message Filter for handling _pptApp COM busy/retry scenarios. +/// Automatically retries when _pptApp returns RPC_E_SERVERCALL_RETRYLATER. /// /// -/// This filter intercepts COM calls to Excel and handles transient "server busy" conditions. -/// When Excel is temporarily busy (e.g., showing a dialog), the filter automatically retries +/// This filter intercepts COM calls to _pptApp and handles transient "server busy" conditions. +/// When _pptApp is temporarily busy (e.g., showing a dialog), the filter automatically retries /// after a short delay rather than throwing an exception. /// /// Register once per STA thread via Register(), revoke on thread shutdown via Revoke(). @@ -161,7 +161,7 @@ int IOleMessageFilter.HandleInComingCall(int dwCallType, nint htaskCaller, int d } /// - /// Handles rejected COM calls from Excel. + /// Handles rejected COM calls from PowerPoint. /// Implements automatic retry logic with exponential backoff for busy/unavailable conditions. /// /// Handle to the task that rejected the call @@ -195,7 +195,7 @@ int IOleMessageFilter.RetryRejectedCall(nint htaskCallee, int dwTickCount, int d // 0-1s: 100ms delays (quick retries for brief busy states) // 1-5s: 200ms delays // 5-15s: 500ms delays - // 15-30s: 1000ms delays (Excel is seriously stuck) + // 15-30s: 1000ms delays (_pptApp is seriously stuck) return dwTickCount switch { < 1000 => 100, @@ -233,12 +233,13 @@ int IOleMessageFilter.MessagePending(nint htaskCallee, int dwTickCount, int dwPe return 2; // PENDINGMSG_WAITDEFPROCESS — dispatch to HandleInComingCall } - // PENDINGMSG_WAITNOPROCESS (1) — queue inbound messages without dispatching. + // PENDINGMSG_WAITDEFPROCESS (2) — dispatch inbound messages via HandleInComingCall. // - // For normal short COM operations (property reads, sheet operations), keep the - // safe default: don't dispatch inbound messages. They are delivered after the - // outgoing call returns. - return 1; // PENDINGMSG_WAITNOPROCESS — queue inbound messages, do not dispatch + // PowerPoint operations like AddChart activate embedded Excel OLE servers, + // which send COM callbacks to our STA thread. These must be dispatched + // so the OLE activation can complete. HandleInComingCall returns + // SERVERCALL_ISHANDLED (0) for normal operations, accepting the callback. + return 2; // PENDINGMSG_WAITDEFPROCESS — dispatch inbound messages } /// diff --git a/src/ExcelMcp.ComInterop/ExcelMcp.ComInterop.csproj b/src/PptMcp.ComInterop/PptMcp.ComInterop.csproj similarity index 64% rename from src/ExcelMcp.ComInterop/ExcelMcp.ComInterop.csproj rename to src/PptMcp.ComInterop/PptMcp.ComInterop.csproj index d152e436..e23e222f 100644 --- a/src/ExcelMcp.ComInterop/ExcelMcp.ComInterop.csproj +++ b/src/PptMcp.ComInterop/PptMcp.ComInterop.csproj @@ -1,13 +1,13 @@ - net10.0 + net9.0 enable enable true - Sbroenne.ExcelMcp.ComInterop - Sbroenne.ExcelMcp.ComInterop + PptMcp.ComInterop + PptMcp.ComInterop $(NoWarn);CA1848 @@ -16,12 +16,12 @@ 1.0.0.0 1.0.0.0 - Sbroenne.ExcelMcp.ComInterop - Sbroenne ExcelMcp COM Interop Library - COM interop utilities for Excel automation by Sbroenne. Provides low-level COM object lifecycle management, STA threading, OLE message filtering, and Excel session management. Foundation library for robust Excel COM automation. - excel;automation;com;interop;ole;sta;threading;excel-automation;windows;sbroenne + PptMcp.ComInterop + PptMcp COM Interop Library + COM interop utilities for PowerPoint automation. Provides low-level COM object lifecycle management, STA threading, OLE message filtering, and PowerPoint session management. Foundation library for robust PowerPoint COM automation. + powerpoint;automation;com;interop;ole;sta;threading;windows README.md - See https://github.com/sbroenne/mcp-server-excel/releases for release notes + See https://github.com/trsdn/mcp-server-ppt/releases for release notes false @@ -32,8 +32,8 @@ - - + + @@ -65,22 +65,17 @@ - - + true NU1701 - + EmbedInteropTypes=true on Microsoft.Office.Interop.PowerPoint embeds all statically-used + Office Core types at compile time, so no runtime copy is needed. --> diff --git a/src/ExcelMcp.ComInterop/Progress/ProgressContext.cs b/src/PptMcp.ComInterop/Progress/ProgressContext.cs similarity index 93% rename from src/ExcelMcp.ComInterop/Progress/ProgressContext.cs rename to src/PptMcp.ComInterop/Progress/ProgressContext.cs index 186faeff..6ece3ab4 100644 --- a/src/ExcelMcp.ComInterop/Progress/ProgressContext.cs +++ b/src/PptMcp.ComInterop/Progress/ProgressContext.cs @@ -1,4 +1,4 @@ -namespace Sbroenne.ExcelMcp.ComInterop; +namespace PptMcp.ComInterop; /// /// Ambient progress context using AsyncLocal. diff --git a/src/ExcelMcp.ComInterop/Progress/ProgressInfo.cs b/src/PptMcp.ComInterop/Progress/ProgressInfo.cs similarity index 93% rename from src/ExcelMcp.ComInterop/Progress/ProgressInfo.cs rename to src/PptMcp.ComInterop/Progress/ProgressInfo.cs index 9e0192b8..edbc6d15 100644 --- a/src/ExcelMcp.ComInterop/Progress/ProgressInfo.cs +++ b/src/PptMcp.ComInterop/Progress/ProgressInfo.cs @@ -1,4 +1,4 @@ -namespace Sbroenne.ExcelMcp.ComInterop; +namespace PptMcp.ComInterop; /// /// Domain-agnostic progress information for long-running operations. diff --git a/src/PptMcp.ComInterop/README.md b/src/PptMcp.ComInterop/README.md new file mode 100644 index 00000000..bc61c843 --- /dev/null +++ b/src/PptMcp.ComInterop/README.md @@ -0,0 +1,60 @@ +# PptMcp.ComInterop + +**Low-level COM interop utilities for PowerPoint automation.** + +## Overview + +This library provides PowerPoint-specific COM object lifecycle management and OLE message filtering. It's the foundation layer for PptMcp projects, handling STA threading, session management, and batch operations specifically for PowerPoint COM automation. + +## Features + +- **STA Threading Management** - Ensures proper single-threaded apartment model for PowerPoint COM objects +- **COM Object Lifecycle** - Automatic COM object cleanup and garbage collection +- **OLE Message Filtering** - Handles busy/rejected COM calls with retry logic using Polly +- **PowerPoint Session Management** - Manages PowerPoint.Application lifecycle safely +- **Batch Operations** - Efficient handling of multiple PowerPoint operations in a single session + +## Usage Example + +```csharp +using PptMcp.ComInterop; + +// Use PptSession for safe PowerPoint automation +await using var session = await PptSession.BeginAsync("path/to/presentation.pptx"); +await using var batch = await session.BeginBatchAsync(); + +await batch.ExecuteAsync(async (ctx, ct) => +{ + // Access PowerPoint presentation through ctx.Book + dynamic slides = ctx.Book.Slides; + dynamic slide = slides.Item(1); + + // Perform PowerPoint operations + slide.Name = "UpdatedSlide"; + + return 0; +}); + +await batch.Save(); +``` + +## Key Classes + +- **PptSession** - Manages PowerPoint.Application lifecycle and presentation operations +- **PptBatch** - Groups multiple operations for efficient execution +- **ComUtilities** - Helper methods for COM object cleanup and safe property access +- **OleMessageFilter** - Implements retry logic for busy PowerPoint instances + +## Requirements + +- Windows OS +- .NET 10.0 or later +- Microsoft PowerPoint 2016+ installed + +## Platform Support + +- ✅ Windows x64 +- ✅ Windows ARM64 +- ❌ Linux (PowerPoint COM not available) +- ❌ macOS (PowerPoint COM not available) + diff --git a/src/ExcelMcp.ComInterop/ServiceClient/ServiceProtocol.cs b/src/PptMcp.ComInterop/ServiceClient/ServiceProtocol.cs similarity index 97% rename from src/ExcelMcp.ComInterop/ServiceClient/ServiceProtocol.cs rename to src/PptMcp.ComInterop/ServiceClient/ServiceProtocol.cs index cf7aedd5..092f7d2e 100644 --- a/src/ExcelMcp.ComInterop/ServiceClient/ServiceProtocol.cs +++ b/src/PptMcp.ComInterop/ServiceClient/ServiceProtocol.cs @@ -1,7 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace Sbroenne.ExcelMcp.ComInterop.ServiceClient; +namespace PptMcp.ComInterop.ServiceClient; /// /// Protocol messages for CLI/MCP-to-service communication over named pipes. diff --git a/src/ExcelMcp.ComInterop/ServiceClient/ServiceSecurity.cs b/src/PptMcp.ComInterop/ServiceClient/ServiceSecurity.cs similarity index 77% rename from src/ExcelMcp.ComInterop/ServiceClient/ServiceSecurity.cs rename to src/PptMcp.ComInterop/ServiceClient/ServiceSecurity.cs index 7e812237..03ad170f 100644 --- a/src/ExcelMcp.ComInterop/ServiceClient/ServiceSecurity.cs +++ b/src/PptMcp.ComInterop/ServiceClient/ServiceSecurity.cs @@ -1,10 +1,10 @@ using System.IO.Pipes; using System.Security.Principal; -namespace Sbroenne.ExcelMcp.ComInterop.ServiceClient; +namespace PptMcp.ComInterop.ServiceClient; /// -/// Security utilities for ExcelMCP Service named pipe communication (client-side). +/// Security utilities for PptMcp Service named pipe communication (client-side). /// Provides pipe name generation and client connection creation. /// public static class ServiceSecurity @@ -16,12 +16,12 @@ public static class ServiceSecurity /// /// Gets the pipe name for the MCP Server (per-process isolation). /// - public static string GetMcpPipeName() => $"excelmcp-mcp-{UserSid}-{Environment.ProcessId}"; + public static string GetMcpPipeName() => $"PptMcp-mcp-{UserSid}-{Environment.ProcessId}"; /// /// Gets the pipe name for the CLI daemon (shared across CLI invocations for the same user). /// - public static string GetCliPipeName() => $"excelmcp-cli-{UserSid}"; + public static string GetCliPipeName() => $"PptMcp-cli-{UserSid}"; /// /// Creates a client connection to a service pipe. diff --git a/src/ExcelMcp.ComInterop/Session/IExcelBatch.cs b/src/PptMcp.ComInterop/Session/IPptBatch.cs similarity index 55% rename from src/ExcelMcp.ComInterop/Session/IExcelBatch.cs rename to src/PptMcp.ComInterop/Session/IPptBatch.cs index 1782262c..f9249b4c 100644 --- a/src/ExcelMcp.ComInterop/Session/IExcelBatch.cs +++ b/src/PptMcp.ComInterop/Session/IPptBatch.cs @@ -1,19 +1,19 @@ -namespace Sbroenne.ExcelMcp.ComInterop.Session; +namespace PptMcp.ComInterop.Session; -using Excel = Microsoft.Office.Interop.Excel; +using PowerPoint = Microsoft.Office.Interop.PowerPoint; /// -/// Represents a batch of Excel operations that share a single Excel instance. +/// Represents a batch of PowerPoint operations that share a single PowerPoint instance. /// Implements IDisposable to ensure proper COM cleanup. /// /// -/// Use this interface via ExcelSession.BeginBatch() for multi-operation workflows. -/// The batch keeps Excel and the workbook open until disposed, enabling efficient -/// execution of multiple commands without repeated Excel startup/shutdown overhead. +/// Use this interface via PptSession.BeginBatch() for multi-operation workflows. +/// The batch keeps PowerPoint and the presentation open until disposed, enabling efficient +/// execution of multiple commands without repeated PowerPoint startup/shutdown overhead. /// /// Lifecycle: /// -/// Created via ExcelSession.BeginBatch(filePath) +/// Created via PptSession.BeginBatch(filePath) /// Operations executed via Execute() /// Optional explicit save via Save() /// Disposed via Dispose() or "using" pattern @@ -21,35 +21,30 @@ namespace Sbroenne.ExcelMcp.ComInterop.Session; /// /// Example: /// -/// using var batch = ExcelSession.BeginBatch("workbook.xlsx"); +/// using var batch = PptSession.BeginBatch("presentation.pptx"); /// /// // Execute COM operations /// batch.Execute((ctx, ct) => { -/// ctx.Book.Worksheets.Add("Data"); +/// ctx.Presentation.Slides.Add(1, 1); /// return 0; /// }); /// -/// batch.Execute((ctx, ct) => { -/// ctx.Book.Worksheets["Data"].Range["A1"].Value = "Header"; -/// return 0; -/// }); -/// -/// // Get content from Excel -/// var content = batch.Execute((ctx, ct) => { -/// return ctx.Book.Range["A1"].Formula; +/// // Get content from PowerPoint +/// var count = batch.Execute((ctx, ct) => { +/// return ctx.Presentation.Slides.Count; /// }); /// /// // Explicit save /// batch.Save(); /// /// -public interface IExcelBatch : IDisposable +public interface IPptBatch : IDisposable { /// - /// Gets the path to the Excel workbook this batch operates on. - /// For multi-workbook batches, this is the primary (first) workbook. + /// Gets the path to the PowerPoint presentation this batch operates on. + /// For multi-presentation batches, this is the primary (first) presentation. /// - string WorkbookPath { get; } + string PresentationPath { get; } /// /// Gets the logger instance for diagnostic output. @@ -58,53 +53,53 @@ public interface IExcelBatch : IDisposable Microsoft.Extensions.Logging.ILogger Logger { get; } /// - /// Gets all workbooks currently open in this batch, keyed by normalized file path. - /// For single-workbook batches, contains one entry. - /// For multi-workbook batches (cross-workbook operations), contains all open workbooks. + /// Gets all presentations currently open in this batch, keyed by normalized file path. + /// For single-presentation batches, contains one entry. + /// For multi-presentation batches (cross-presentation operations), contains all open presentations. /// - IReadOnlyDictionary Workbooks { get; } + IReadOnlyDictionary Presentations { get; } /// - /// Gets the COM Workbook object for a specific file path. + /// Gets the COM Presentation object for a specific file path. /// - /// Path to the workbook (will be normalized) - /// Excel.Workbook COM object - /// Workbook not found in this batch - Excel.Workbook GetWorkbook(string filePath); + /// Path to the presentation (will be normalized) + /// PowerPoint.Presentation COM object + /// Presentation not found in this batch + PowerPoint.Presentation GetPresentation(string filePath); /// /// Executes a void COM operation within this batch. - /// The operation receives an ExcelContext with access to the Excel app and workbook. + /// The operation receives a PptContext with access to the PowerPoint app and presentation. /// Use this overload for void operations that don't need to return values. - /// All Excel COM operations are synchronous - file I/O should be handled outside the batch. + /// All PowerPoint COM operations are synchronous - file I/O should be handled outside the batch. /// /// COM operation to execute /// Optional cancellation token for caller-controlled cancellation /// Batch has already been disposed - /// Excel COM error occurred + /// PowerPoint COM error occurred /// Operation was cancelled via cancellationToken void Execute( - Action operation, + Action operation, CancellationToken cancellationToken = default); /// /// Executes a COM operation within this batch. - /// The operation receives an ExcelContext with access to the Excel app and workbook. - /// All Excel COM operations are synchronous - file I/O should be handled outside the batch. + /// The operation receives a PptContext with access to the PowerPoint app and presentation. + /// All PowerPoint COM operations are synchronous - file I/O should be handled outside the batch. /// /// Return type of the operation /// COM operation to execute /// Optional cancellation token for caller-controlled cancellation /// Result of the operation /// Batch has already been disposed - /// Excel COM error occurred + /// PowerPoint COM error occurred /// Operation was cancelled via cancellationToken T Execute( - Func operation, + Func operation, CancellationToken cancellationToken = default); /// - /// Saves changes to the workbook. + /// Saves changes to the presentation. /// This is an explicit save - changes are NOT automatically saved on dispose. /// /// Optional cancellation token for caller-controlled cancellation @@ -114,23 +109,23 @@ T Execute( void Save(CancellationToken cancellationToken = default); /// - /// Checks if the underlying Excel process is still alive. + /// Checks if the underlying PowerPoint process is still alive. /// /// - /// True if Excel process exists and hasn't exited. + /// True if PowerPoint process exists and hasn't exited. /// False if process has crashed, was killed, or process ID wasn't captured. /// /// - /// Use this to detect dead Excel processes before attempting operations. + /// Use this to detect dead PowerPoint processes before attempting operations. /// If this returns false, the session should be closed and recreated. /// - bool IsExcelProcessAlive(); + bool IsPowerPointProcessAlive(); /// - /// Gets the Excel process ID, if captured. + /// Gets the PowerPoint process ID, if captured. /// /// Process ID, or null if not captured during startup. - int? ExcelProcessId { get; } + int? PowerPointProcessId { get; } /// /// Gets the operation timeout for this batch. diff --git a/src/ExcelMcp.ComInterop/Session/ExcelBatch.cs b/src/PptMcp.ComInterop/Session/PptBatch.cs similarity index 57% rename from src/ExcelMcp.ComInterop/Session/ExcelBatch.cs rename to src/PptMcp.ComInterop/Session/PptBatch.cs index b5335aa9..79c91374 100644 --- a/src/ExcelMcp.ComInterop/Session/ExcelBatch.cs +++ b/src/PptMcp.ComInterop/Session/PptBatch.cs @@ -2,94 +2,94 @@ using System.Threading.Channels; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -using Excel = Microsoft.Office.Interop.Excel; +using PowerPoint = Microsoft.Office.Interop.PowerPoint; -namespace Sbroenne.ExcelMcp.ComInterop.Session; +namespace PptMcp.ComInterop.Session; /// -/// Implementation of IExcelBatch that manages a single Excel instance on a dedicated STA thread. -/// Ensures proper COM interop with Excel using STA apartment state and OLE message filter. +/// Implementation of IPptBatch that manages a single PowerPoint instance on a dedicated STA thread. +/// Ensures proper COM interop with PowerPoint using STA apartment state and OLE message filter. /// /// -/// CRITICAL: Excel COM Threading Model +/// CRITICAL: PowerPoint COM Threading Model /// -/// Each ExcelBatch runs on ONE dedicated STA (Single-Threaded Apartment) thread +/// Each PptBatch runs on ONE dedicated STA (Single-Threaded Apartment) thread /// Operations are queued via Channel and executed SERIALLY (never in parallel) /// Multiple simultaneous Execute() calls are processed one at a time /// This is a COM interop requirement, not an implementation choice /// For parallel processing, create multiple sessions for DIFFERENT files /// -/// Resource Cost: Each ExcelBatch = one Excel.Application process (~50-100MB+ memory) +/// Resource Cost: Each PptBatch = one PowerPoint.Application process (~50-100MB+ memory) /// -internal sealed class ExcelBatch : IExcelBatch +internal sealed class PptBatch : IPptBatch { // P/Invoke for getting process ID from window handle [DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); - private readonly string _workbookPath; // Primary workbook path - private readonly string[] _allWorkbookPaths; // All workbook paths (includes primary) - private readonly bool _showExcel; // Whether to show Excel window + private readonly string _presentationPath; // Primary presentation path + private readonly string[] _allPresentationPaths; // All presentation paths (includes primary) + private readonly bool _showPowerPoint; // Whether to show PowerPoint window private readonly bool _createNewFile; // Whether to create a new file instead of opening existing - private readonly bool _isMacroEnabled; // For new files: whether to create .xlsm (macro-enabled) + private readonly bool _isMacroEnabled; // For new files: whether to create .pptm (macro-enabled) private readonly TimeSpan _operationTimeout; // Timeout for individual operations - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly Channel> _workQueue; private readonly Thread _staThread; private readonly CancellationTokenSource _shutdownCts; private int _disposed; // 0 = not disposed, 1 = disposed (using int for Interlocked.CompareExchange) - private int? _excelProcessId; // Excel.exe process ID for force-kill if needed + private int? _powerPointProcessId; // POWERPNT.exe process ID for force-kill if needed private bool _operationTimedOut; // Track if an operation timed out for aggressive cleanup // COM state (STA thread only) - private Excel.Application? _excel; - private Excel.Workbook? _workbook; // Primary workbook - private Dictionary? _workbooks; // All workbooks keyed by normalized path - private ExcelContext? _context; + private PowerPoint.Application? _powerPoint; + private PowerPoint.Presentation? _presentation; // Primary presentation + private Dictionary? _presentations; // All presentations keyed by normalized path + private PptContext? _context; /// - /// Creates a new ExcelBatch for one or more workbooks. - /// All workbooks are opened in the same Excel.Application instance, enabling cross-workbook operations. + /// Creates a new PptBatch for one or more presentations. + /// All presentations are opened in the same PowerPoint.Application instance, enabling cross-presentation operations. /// - /// Paths to Excel workbooks. First path is the primary workbook. + /// Paths to PowerPoint presentations. First path is the primary presentation. /// Optional logger for diagnostic output. If null, uses NullLogger (no output). - /// Whether to show the Excel window (default: false for background automation). + /// Whether to show the PowerPoint window (default: false for background automation). /// Timeout for individual operations. Default: 5 minutes. - public ExcelBatch(string[] workbookPaths, ILogger? logger = null, bool show = false, TimeSpan? operationTimeout = null) - : this(workbookPaths, logger, show, createNewFile: false, isMacroEnabled: false, operationTimeout: operationTimeout) + public PptBatch(string[] presentationPaths, ILogger? logger = null, bool show = false, TimeSpan? operationTimeout = null) + : this(presentationPaths, logger, show, createNewFile: false, isMacroEnabled: false, operationTimeout: operationTimeout) { } /// - /// Creates a new ExcelBatch that creates a new workbook file instead of opening an existing one. + /// Creates a new PptBatch that creates a new presentation file instead of opening an existing one. /// The file is saved immediately after creation, then kept open in the session. /// - /// Path where the new Excel file will be created. - /// Whether to create .xlsm (macro-enabled) format. + /// Path where the new PowerPoint file will be created. + /// Whether to create .pptm (macro-enabled) format. /// Optional logger for diagnostic output. - /// Whether to show the Excel window. + /// Whether to show the PowerPoint window. /// Timeout for individual operations. Default: 5 minutes. - /// ExcelBatch instance with the new workbook open. - internal static ExcelBatch CreateNewWorkbook(string filePath, bool isMacroEnabled, ILogger? logger = null, bool show = false, TimeSpan? operationTimeout = null) + /// PptBatch instance with the new presentation open. + internal static PptBatch CreateNewPresentation(string filePath, bool isMacroEnabled, ILogger? logger = null, bool show = false, TimeSpan? operationTimeout = null) { - return new ExcelBatch([filePath], logger, show, createNewFile: true, isMacroEnabled: isMacroEnabled, operationTimeout: operationTimeout); + return new PptBatch([filePath], logger, show, createNewFile: true, isMacroEnabled: isMacroEnabled, operationTimeout: operationTimeout); } /// /// Private constructor that handles both opening existing files and creating new ones. /// - private ExcelBatch(string[] workbookPaths, ILogger? logger, bool show, bool createNewFile, bool isMacroEnabled, TimeSpan? operationTimeout = null) + private PptBatch(string[] presentationPaths, ILogger? logger, bool show, bool createNewFile, bool isMacroEnabled, TimeSpan? operationTimeout = null) { - if (workbookPaths == null || workbookPaths.Length == 0) - throw new ArgumentException("At least one workbook path is required", nameof(workbookPaths)); + if (presentationPaths == null || presentationPaths.Length == 0) + throw new ArgumentException("At least one presentation path is required", nameof(presentationPaths)); - _allWorkbookPaths = workbookPaths; - _workbookPath = workbookPaths[0]; // Primary workbook - _showExcel = show; + _allPresentationPaths = presentationPaths; + _presentationPath = presentationPaths[0]; // Primary presentation + _showPowerPoint = show; _createNewFile = createNewFile; _isMacroEnabled = isMacroEnabled; _operationTimeout = operationTimeout ?? ComInteropConstants.DefaultOperationTimeout; - _logger = logger ?? NullLogger.Instance; + _logger = logger ?? NullLogger.Instance; _shutdownCts = new CancellationTokenSource(); // Create unbounded channel for work items @@ -106,93 +106,87 @@ private ExcelBatch(string[] workbookPaths, ILogger? logger, bool sho { try { - // CRITICAL: Register OLE message filter on STA thread for Excel busy handling + // CRITICAL: Register OLE message filter on STA thread for PowerPoint busy handling OleMessageFilter.Register(); - // Create Excel and workbook ON THIS STA THREAD - Type? excelType = Type.GetTypeFromProgID("Excel.Application"); - if (excelType == null) + // Create PowerPoint ON THIS STA THREAD + Type? pptType = Type.GetTypeFromProgID("PowerPoint.Application"); + if (pptType == null) { - throw new InvalidOperationException("Microsoft Excel is not installed on this system."); + throw new InvalidOperationException("Microsoft PowerPoint is not installed on this system."); } - Excel.Application tempExcel = (Excel.Application)Activator.CreateInstance(excelType)!; - tempExcel.Visible = _showExcel; - tempExcel.DisplayAlerts = false; + PowerPoint.Application tempPowerPoint = (PowerPoint.Application)Activator.CreateInstance(pptType)!; + // PowerPoint COM does NOT allow hiding the application window (unlike Excel). + // Setting Visible = msoFalse (0) throws "Hiding the application window is not allowed." + // Always set Visible = msoTrue (-1). Use WindowState to minimize if needed. + ((dynamic)tempPowerPoint).Visible = -1; // msoTrue — required by PowerPoint COM + ((dynamic)tempPowerPoint).DisplayAlerts = 0; // ppAlertsNone + if (!_showPowerPoint) + { + // Minimize instead of hiding — PowerPoint doesn't allow Visible=false + ((dynamic)tempPowerPoint).WindowState = 2; // ppWindowMinimized + } - // Capture Excel process ID for force-kill scenarios (hung Excel, dead RPC connection) + // Capture PowerPoint process ID for force-kill scenarios (hung PowerPoint, dead RPC connection) try { - // Excel.Application.Hwnd returns the window handle - // Use GetWindowThreadProcessId to get process ID directly from Hwnd - // This works even for hidden Excel windows (Visible=false) - int hwnd = tempExcel.Hwnd; + // PowerPoint.Application.HWND returns the window handle + int hwnd = tempPowerPoint.HWND; if (hwnd != 0) { uint processId = 0; _ = GetWindowThreadProcessId(new IntPtr(hwnd), out processId); if (processId != 0) { - _excelProcessId = (int)processId; - _logger.LogDebug("Captured Excel process ID via Hwnd: {ProcessId}", _excelProcessId); + _powerPointProcessId = (int)processId; + _logger.LogDebug("Captured PowerPoint process ID via HWND: {ProcessId}", _powerPointProcessId); } } - // NOTE: No fallback PID capture. - // Previously this fell back to "newest EXCEL.EXE process" when Hwnd returned 0. - // That approach was unsafe: if the user has their own Excel open, we would - // incorrectly identify and later force-kill their instance (GitHub #482, Bug 2). - // Disabling force-kill is safer than killing the wrong process. - if (!_excelProcessId.HasValue) + if (!_powerPointProcessId.HasValue) { _logger.LogWarning( - "Could not determine Excel process ID via Hwnd. " + - "Force-kill will be disabled for this session to avoid killing unrelated Excel instances."); + "Could not determine PowerPoint process ID via HWND. " + + "Force-kill will be disabled for this session to avoid killing unrelated PowerPoint instances."); } } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to capture Excel process ID. Force-kill will not be available."); + _logger.LogWarning(ex, "Failed to capture PowerPoint process ID. Force-kill will not be available."); } - // Disable macro security warnings for unattended automation - // msoAutomationSecurityForceDisable = 3 (disable all macros, no prompts) - // See: https://learn.microsoft.com/en-us/office/vba/api/word.application.automationsecurity - // PIA gap: MsoAutomationSecurity is in office.dll (Microsoft.Office.Core) which is NOT bundled - // with the Excel PIA NuGet package. Casting tempExcel to (object) first forces pure IDispatch - // binding so the DLR never tries to load office.dll to resolve the MsoAutomationSecurity type. - // Without (object) cast: ((dynamic)Excel.Application) retains COM type metadata → office.dll load → crash. - ((dynamic)(object)tempExcel).AutomationSecurity = 3; - - // Open or create workbooks in the same Excel instance - var tempWorkbooks = new Dictionary(StringComparer.OrdinalIgnoreCase); - Excel.Workbook? primaryWorkbook = null; + // Open or create presentations in the same PowerPoint instance + var tempPresentations = new Dictionary(StringComparer.OrdinalIgnoreCase); + PowerPoint.Presentation? primaryPresentation = null; - foreach (var path in _allWorkbookPaths) + foreach (var path in _allPresentationPaths) { - Excel.Workbook wb; + PowerPoint.Presentation pres; string normalizedPath = Path.GetFullPath(path); if (_createNewFile) { // CREATE NEW FILE: Use Add() + SaveAs() instead of Open() - // Validate directory exists (do not create automatically) string? directory = Path.GetDirectoryName(normalizedPath); if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) { - throw new DirectoryNotFoundException($"Directory does not exist: '{directory}'. Create the directory first before creating Excel files."); + throw new DirectoryNotFoundException($"Directory does not exist: '{directory}'. Create the directory first before creating PowerPoint files."); } - wb = (Excel.Workbook)tempExcel.Workbooks.Add(); + // Create new presentation WITH window (required for embedded objects like charts) + // PowerPoint's AddChart/AddChart2 requires a DocumentWindow to exist. + // Using Add(msoFalse) creates without window and breaks chart/OLE operations. + pres = ((dynamic)tempPowerPoint).Presentations.Add(); // SaveAs with appropriate format if (_isMacroEnabled) { - wb.SaveAs(normalizedPath, ComInteropConstants.XlOpenXmlWorkbookMacroEnabled); + ((dynamic)pres).SaveAs(normalizedPath, ComInteropConstants.PpSaveAsOpenXMLPresentationMacroEnabled); } else { - wb.SaveAs(normalizedPath, ComInteropConstants.XlOpenXmlWorkbook); + ((dynamic)pres).SaveAs(normalizedPath, ComInteropConstants.PpSaveAsOpenXMLPresentation); } } else @@ -202,47 +196,43 @@ private ExcelBatch(string[] workbookPaths, ILogger? logger, bool sho if (isIrm) { - // IRM/AIP-protected files are OLE2 containers that cannot be opened - // exclusively. Excel must be visible so the user can authenticate - // through the IRM credential prompt. - tempExcel.Visible = true; + ((dynamic)tempPowerPoint).Visible = -1 /* msoTrue */; _logger.LogDebug( - "IRM-protected file detected: {FileName}. Forcing Excel visible and opening read-only.", + "IRM-protected file detected: {FileName}. Forcing PowerPoint visible and opening read-only.", Path.GetFileName(normalizedPath)); } else { - // CRITICAL: Check if file is locked at OS level BEFORE attempting Excel COM open + // CRITICAL: Check if file is locked at OS level BEFORE attempting PowerPoint COM open FileAccessValidator.ValidateFileNotLocked(path); } - // Open workbook with Excel COM + // Open presentation with PowerPoint COM try { - wb = isIrm - // ReadOnly=true prevents "exclusive access required" errors on IRM-encrypted files - ? (Excel.Workbook)tempExcel.Workbooks.Open(normalizedPath, ReadOnly: true) - : (Excel.Workbook)tempExcel.Workbooks.Open(path); + pres = isIrm + ? ((dynamic)tempPowerPoint).Presentations.Open(normalizedPath, -1 /* msoTrue ReadOnly */) + : ((dynamic)tempPowerPoint).Presentations.Open(normalizedPath); } catch (COMException ex) when (ex.HResult == unchecked((int)0x800A03EC)) { - // Excel Error 1004 - File is already open or locked + // Error 1004 equivalent - File is already open or locked throw FileAccessValidator.CreateFileLockedError(path, ex); } } - tempWorkbooks[normalizedPath] = wb; + tempPresentations[normalizedPath] = pres; - if (path == _workbookPath) + if (path == _presentationPath) { - primaryWorkbook = wb; + primaryPresentation = pres; } } - _excel = tempExcel; - _workbook = primaryWorkbook; - _workbooks = tempWorkbooks; - _context = new ExcelContext(_workbookPath, _excel, _workbook!); + _powerPoint = tempPowerPoint; + _presentation = primaryPresentation; + _presentations = tempPresentations; + _context = new PptContext(_presentationPath, _powerPoint, _presentation!); started.SetResult(); @@ -251,13 +241,13 @@ private ExcelBatch(string[] workbookPaths, ILogger? logger, bool sho // // Why WaitToReadAsync and not polling: // 1. Thread.Sleep(10) on an STA thread with registered OLE message filter is unreliable. - // Pending COM messages (Excel events during calculation) cause Sleep to return + // Pending COM messages (_pptApp events during calculation) cause Sleep to return // immediately via MsgWaitForMultipleObjectsEx, turning the loop into a 100% CPU spin. // 2. The previous outer catch(Exception){} silently bypassed Thread.Sleep when any // exception occurred, causing tight spin loops with zero backoff. // 3. WaitToReadAsync().AsTask().GetAwaiter().GetResult() blocks the thread efficiently // and wakes instantly when work arrives. No COM message pumping occurs during the - // block, but that's fine — we don't host COM objects or subscribe to Excel events, + // block, but that's fine — we don't host COM objects or subscribe to _pptApp events, // so no inbound COM messages need dispatching while idle. COM calls within work items // pump messages internally via CoWaitForMultipleHandles. while (true) @@ -269,7 +259,7 @@ private ExcelBatch(string[] workbookPaths, ILogger? logger, bool sho .AsTask().GetAwaiter().GetResult()) { // Channel completed (writer called Complete()) — exit gracefully - _logger.LogDebug("Channel completed, exiting message pump for {FileName}", Path.GetFileName(_workbookPath)); + _logger.LogDebug("Channel completed, exiting message pump for {FileName}", Path.GetFileName(_presentationPath)); break; } @@ -292,7 +282,7 @@ private ExcelBatch(string[] workbookPaths, ILogger? logger, bool sho // Shutdown requested via _shutdownCts. // Drain any remaining work items so in-flight Execute() callers get their // results/exceptions promptly instead of waiting for the 5-minute timeout. - // This is safe: Excel COM objects are still alive (cleaned up in the finally + // This is safe: _pptApp COM objects are still alive (cleaned up in the finally // block below), and Writer.Complete() prevents new items from arriving. while (_workQueue.Reader.TryRead(out var remainingWork)) { @@ -306,7 +296,7 @@ private ExcelBatch(string[] workbookPaths, ILogger? logger, bool sho } } - _logger.LogDebug("Shutdown requested, exiting message pump for {FileName}", Path.GetFileName(_workbookPath)); + _logger.LogDebug("Shutdown requested, exiting message pump for {FileName}", Path.GetFileName(_presentationPath)); break; } } @@ -318,65 +308,67 @@ private ExcelBatch(string[] workbookPaths, ILogger? logger, bool sho finally { // Cleanup COM objects on STA thread exit - _logger.LogDebug("STA thread cleanup starting for {FileName}", Path.GetFileName(_workbookPath)); + _logger.LogDebug("STA thread cleanup starting for {FileName}", Path.GetFileName(_presentationPath)); - // For multi-workbook batches, close all workbooks individually before quitting Excel - if (_workbooks != null && _workbooks.Count > 1) + // For multi-Presentation batches, close all Presentations individually before quitting PowerPoint + if (_presentations != null && _presentations.Count > 1) { - _logger.LogDebug("Closing {Count} workbooks", _workbooks.Count); - foreach (var kvp in _workbooks.ToList()) + _logger.LogDebug("Closing {Count} Presentations", _presentations.Count); + foreach (var kvp in _presentations.ToList()) { try { - Excel.Workbook? wb = kvp.Value; - wb.Close(false); // Don't save - explicit save must be called - Marshal.ReleaseComObject(wb); - wb = null; + PowerPoint.Presentation? pres = kvp.Value; + // Suppress save-changes dialog + try { ((dynamic)pres).Saved = -1; } catch { } + pres.Close(); + Marshal.ReleaseComObject(pres); + pres = null; } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to close workbook {Path}", kvp.Key); + _logger.LogWarning(ex, "Failed to close Presentation {Path}", kvp.Key); } } - _workbooks.Clear(); + _presentations.Clear(); - // Quit Excel after all workbooks closed - if (_excel != null) + // Quit _pptApp after all Presentations closed + if (_powerPoint != null) { try { - _logger.LogDebug("Quitting Excel application"); - _excel.Quit(); + _logger.LogDebug("Quitting _pptApp application"); + _powerPoint.Quit(); } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to quit Excel"); + _logger.LogWarning(ex, "Failed to quit PowerPoint"); } finally { - Marshal.ReleaseComObject(_excel); - _excel = null; + Marshal.ReleaseComObject(_powerPoint); + _powerPoint = null; } } } else { - // Single workbook: use ExcelShutdownService for resilient shutdown - ExcelShutdownService.CloseAndQuit(_workbook, _excel, false, _workbookPath, _logger); + // Single Presentation: use PptShutdownService for resilient shutdown + PptShutdownService.CloseAndQuit(_presentation, _powerPoint, false, _presentationPath, _logger); } - _workbook = null; - _excel = null; - _workbooks = null; + _presentation = null; + _powerPoint = null; + _presentations = null; _context = null; OleMessageFilter.Revoke(); - _logger.LogDebug("STA thread cleanup completed for {FileName}", Path.GetFileName(_workbookPath)); + _logger.LogDebug("STA thread cleanup completed for {FileName}", Path.GetFileName(_presentationPath)); } }) { IsBackground = true, - Name = $"ExcelBatch-{Path.GetFileName(_workbookPath)}" + Name = $"PptBatch-{Path.GetFileName(_presentationPath)}" }; // CRITICAL: Set STA apartment state before starting thread @@ -387,22 +379,22 @@ private ExcelBatch(string[] workbookPaths, ILogger? logger, bool sho started.Task.GetAwaiter().GetResult(); } - public string WorkbookPath => _workbookPath; + public string PresentationPath => _presentationPath; public ILogger Logger => _logger; - public int? ExcelProcessId => _excelProcessId; + public int? PowerPointProcessId => _powerPointProcessId; public TimeSpan OperationTimeout => _operationTimeout; - public bool IsExcelProcessAlive() + public bool IsPowerPointProcessAlive() { if (_disposed != 0) return false; - if (!_excelProcessId.HasValue) return false; + if (!_powerPointProcessId.HasValue) return false; try { - using var proc = System.Diagnostics.Process.GetProcessById(_excelProcessId.Value); + using var proc = System.Diagnostics.Process.GetProcessById(_powerPointProcessId.Value); return !proc.HasExited; } catch (ArgumentException) @@ -412,38 +404,38 @@ public bool IsExcelProcessAlive() } } - public IReadOnlyDictionary Workbooks + public IReadOnlyDictionary Presentations { get { - ObjectDisposedException.ThrowIf(_disposed != 0, nameof(ExcelBatch)); - return _workbooks ?? throw new InvalidOperationException("Workbooks not initialized"); + ObjectDisposedException.ThrowIf(_disposed != 0, nameof(PptBatch)); + return _presentations ?? throw new InvalidOperationException("Presentations not initialized"); } } - public Excel.Workbook GetWorkbook(string filePath) + public PowerPoint.Presentation GetPresentation(string filePath) { - ObjectDisposedException.ThrowIf(_disposed != 0, nameof(ExcelBatch)); + ObjectDisposedException.ThrowIf(_disposed != 0, nameof(PptBatch)); - if (_workbooks == null) - throw new InvalidOperationException("Workbooks not initialized"); + if (_presentations == null) + throw new InvalidOperationException("Presentations not initialized"); string normalizedPath = Path.GetFullPath(filePath); - if (_workbooks.TryGetValue(normalizedPath, out var workbook)) + if (_presentations.TryGetValue(normalizedPath, out var Presentation)) { - return workbook; + return Presentation; } - throw new KeyNotFoundException($"Workbook '{filePath}' is not open in this batch."); + throw new KeyNotFoundException($"Presentation '{filePath}' is not open in this batch."); } /// /// Executes a void COM operation on the STA thread. /// Use this overload for operations that don't need to return values. - /// All Excel COM operations are synchronous. + /// All _pptApp COM operations are synchronous. /// public void Execute( - Action operation, + Action operation, CancellationToken cancellationToken = default) { // Delegate to generic Execute with dummy return @@ -456,13 +448,13 @@ public void Execute( /// /// Executes a COM operation on the STA thread. - /// All Excel COM operations are synchronous. + /// All _pptApp COM operations are synchronous. /// public T Execute( - Func operation, + Func operation, CancellationToken cancellationToken = default) { - ObjectDisposedException.ThrowIf(_disposed != 0, nameof(ExcelBatch)); + ObjectDisposedException.ThrowIf(_disposed != 0, nameof(PptBatch)); // Fail fast if a previous operation timed out or was cancelled while the STA thread // was stuck in IDispatch.Invoke. The STA thread cannot process new work items until @@ -471,17 +463,17 @@ public T Execute( if (_operationTimedOut) { throw new TimeoutException( - $"A previous operation timed out or was cancelled for '{Path.GetFileName(_workbookPath)}'. " + - "The Excel COM thread may be unresponsive. Please close this session and create a new one."); + $"A previous operation timed out or was cancelled for '{Path.GetFileName(_presentationPath)}'. " + + "The _pptApp COM thread may be unresponsive. Please close this session and create a new one."); } - // Check if Excel process is still alive before attempting operation - if (!IsExcelProcessAlive()) + // Check if _pptApp process is still alive before attempting operation + if (!IsPowerPointProcessAlive()) { - _logger.LogError("Excel process is no longer running for workbook {FileName}", Path.GetFileName(_workbookPath)); + _logger.LogError("_pptApp process is no longer running for Presentation {FileName}", Path.GetFileName(_presentationPath)); throw new InvalidOperationException( - $"Excel process is no longer running for workbook '{Path.GetFileName(_workbookPath)}'. " + - "The Excel application may have been closed manually or crashed. " + + $"_pptApp process is no longer running for Presentation '{Path.GetFileName(_presentationPath)}'. " + + "The _pptApp application may have been closed manually or crashed. " + "Please close this session and create a new one."); } @@ -527,8 +519,8 @@ public T Execute( { // Dispose() completed the channel between our _disposed check and WriteAsync. // The session is shutting down — report as disposed. - throw new ObjectDisposedException(nameof(ExcelBatch), - $"Session for '{Path.GetFileName(_workbookPath)}' was disposed while submitting an operation."); + throw new ObjectDisposedException(nameof(PptBatch), + $"Session for '{Path.GetFileName(_presentationPath)}' was disposed while submitting an operation."); } // Wait for operation to complete with timeout. @@ -553,16 +545,16 @@ public T Execute( catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { // Session timeout occurred (not caller cancellation) — only happens in the else branch - _logger.LogError("Operation timed out after {Timeout} for {FileName}", _operationTimeout, Path.GetFileName(_workbookPath)); + _logger.LogError("Operation timed out after {Timeout} for {FileName}", _operationTimeout, Path.GetFileName(_presentationPath)); _operationTimedOut = true; // Mark timeout for aggressive cleanup during disposal throw new TimeoutException( - $"Excel operation timed out after {_operationTimeout.TotalSeconds} seconds for '{Path.GetFileName(_workbookPath)}'. " + - "Excel may be unresponsive or the operation is taking longer than expected. " + + $"_pptApp operation timed out after {_operationTimeout.TotalSeconds} seconds for '{Path.GetFileName(_presentationPath)}'. " + + "_pptApp may be unresponsive or the operation is taking longer than expected. " + "Consider increasing timeoutSeconds when opening the session."); } catch (OperationCanceledException) { - _logger.LogDebug("Operation cancelled or timed out for {FileName}", Path.GetFileName(_workbookPath)); + _logger.LogDebug("Operation cancelled or timed out for {FileName}", Path.GetFileName(_presentationPath)); _operationTimedOut = true; // STA thread may still be blocked — session is unusable throw; } @@ -572,9 +564,9 @@ public void Save(CancellationToken cancellationToken = default) { Execute((ctx, ct) => { - ExcelShutdownService.SaveWorkbookWithTimeout( - _workbook!, - Path.GetFileName(_workbookPath), + PptShutdownService.SavePresentationWithTimeout( + _presentation!, + Path.GetFileName(_presentationPath), _logger, ct); return 0; @@ -589,55 +581,55 @@ public void Dispose() // Returns 0 if exchange succeeded (was not disposed), 1 if already disposed if (Interlocked.CompareExchange(ref _disposed, 1, 0) != 0) { - _logger.LogDebug("[Thread {CallingThread}] Dispose skipped - already disposed for {FileName}", callingThread, Path.GetFileName(_workbookPath)); + _logger.LogDebug("[Thread {CallingThread}] Dispose skipped - already disposed for {FileName}", callingThread, Path.GetFileName(_presentationPath)); return; // Already disposed } - _logger.LogDebug("[Thread {CallingThread}] Dispose starting for {FileName}", callingThread, Path.GetFileName(_workbookPath)); + _logger.LogDebug("[Thread {CallingThread}] Dispose starting for {FileName}", callingThread, Path.GetFileName(_presentationPath)); // Cancel the shutdown token FIRST to wake up the message pump - _logger.LogDebug("[Thread {CallingThread}] Cancelling shutdown token for {FileName}", callingThread, Path.GetFileName(_workbookPath)); + _logger.LogDebug("[Thread {CallingThread}] Cancelling shutdown token for {FileName}", callingThread, Path.GetFileName(_presentationPath)); _shutdownCts.Cancel(); // Then complete the work queue - _logger.LogDebug("[Thread {CallingThread}] Completing work queue for {FileName}", callingThread, Path.GetFileName(_workbookPath)); + _logger.LogDebug("[Thread {CallingThread}] Completing work queue for {FileName}", callingThread, Path.GetFileName(_presentationPath)); _workQueue.Writer.Complete(); - _logger.LogDebug("[Thread {CallingThread}] Waiting for STA thread (Id={STAThread}) to exit for {FileName}", callingThread, _staThread?.ManagedThreadId ?? -1, Path.GetFileName(_workbookPath)); + _logger.LogDebug("[Thread {CallingThread}] Waiting for STA thread (Id={STAThread}) to exit for {FileName}", callingThread, _staThread?.ManagedThreadId ?? -1, Path.GetFileName(_presentationPath)); // When operation timed out, the STA thread is stuck in IDispatch.Invoke (unmanaged COM call - // that cannot be cancelled). Kill the Excel process FIRST to unblock the STA thread, then wait. - if (_operationTimedOut && _excelProcessId.HasValue && _staThread != null && _staThread.IsAlive) + // that cannot be cancelled). Kill the _pptApp process FIRST to unblock the STA thread, then wait. + if (_operationTimedOut && _powerPointProcessId.HasValue && _staThread != null && _staThread.IsAlive) { _logger.LogWarning( - "[Thread {CallingThread}] Operation timed out — force-killing Excel process {ProcessId} BEFORE waiting for STA thread to unblock IDispatch.Invoke for {FileName}", - callingThread, _excelProcessId.Value, Path.GetFileName(_workbookPath)); + "[Thread {CallingThread}] Operation timed out — force-killing _pptApp process {ProcessId} BEFORE waiting for STA thread to unblock IDispatch.Invoke for {FileName}", + callingThread, _powerPointProcessId.Value, Path.GetFileName(_presentationPath)); try { - using var excelProcess = System.Diagnostics.Process.GetProcessById(_excelProcessId.Value); - if (!excelProcess.HasExited) + using var pptProcess = System.Diagnostics.Process.GetProcessById(_powerPointProcessId.Value); + if (!pptProcess.HasExited) { - excelProcess.Kill(); - excelProcess.WaitForExit(5000); + pptProcess.Kill(); + pptProcess.WaitForExit(5000); _logger.LogInformation( - "[Thread {CallingThread}] Force-killed Excel process {ProcessId} (pre-emptive, before STA join)", - callingThread, _excelProcessId.Value); + "[Thread {CallingThread}] Force-killed _pptApp process {ProcessId} (pre-emptive, before STA join)", + callingThread, _powerPointProcessId.Value); } } catch (ArgumentException) { - _logger.LogDebug("[Thread {CallingThread}] Excel process {ProcessId} already exited", callingThread, _excelProcessId.Value); + _logger.LogDebug("[Thread {CallingThread}] _pptApp process {ProcessId} already exited", callingThread, _powerPointProcessId.Value); } catch (Exception ex) { - _logger.LogWarning(ex, "[Thread {CallingThread}] Failed to force-kill Excel process {ProcessId}", callingThread, _excelProcessId.Value); + _logger.LogWarning(ex, "[Thread {CallingThread}] Failed to force-kill _pptApp process {ProcessId}", callingThread, _powerPointProcessId.Value); } } // Wait for STA thread to finish cleanup (with timeout) if (_staThread != null && _staThread.IsAlive) { - // Use shorter timeout if operation timed out (Excel is likely hung / already killed above) + // Use shorter timeout if operation timed out (_pptApp is likely hung / already killed above) var joinTimeout = _operationTimedOut ? TimeSpan.FromSeconds(10) // Aggressive: 10 seconds when operation timed out : ComInteropConstants.StaThreadJoinTimeout; // Normal: 45 seconds @@ -645,36 +637,36 @@ public void Dispose() var reasonSuffix = _operationTimedOut ? " (operation timed out - aggressive cleanup)" : ""; _logger.LogDebug( "[Thread {CallingThread}] Calling Join() with {Timeout} timeout on STA={STAThread}, file={FileName}{Reason}", - callingThread, joinTimeout, _staThread.ManagedThreadId, Path.GetFileName(_workbookPath), reasonSuffix); + callingThread, joinTimeout, _staThread.ManagedThreadId, Path.GetFileName(_presentationPath), reasonSuffix); - // CRITICAL: StaThreadJoinTimeout >= ExcelQuitTimeout + margin (currently 45 seconds total). + // CRITICAL: StaThreadJoinTimeout >= PowerPointQuitTimeout + margin (currently 45 seconds total). // The join must wait at least as long as CloseAndQuit() can take, otherwise Dispose() returns - // before Excel has finished closing, causing "file still open" issues in subsequent operations. + // before _pptApp has finished closing, causing "file still open" issues in subsequent operations. if (!_staThread.Join(joinTimeout)) { - // STA thread didn't exit - Excel cleanup is severely stuck + // STA thread didn't exit - _pptApp cleanup is severely stuck var reasonForError = _operationTimedOut ? " (operation previously timed out)" : ""; _logger.LogError( "[Thread {CallingThread}] STA thread (Id={STAThread}) did NOT exit within {Timeout} for {FileName}. " + - "Excel cleanup is severely stuck{Reason}. Attempting force-kill.", - callingThread, _staThread.ManagedThreadId, joinTimeout, Path.GetFileName(_workbookPath), reasonForError); + "_pptApp cleanup is severely stuck{Reason}. Attempting force-kill.", + callingThread, _staThread.ManagedThreadId, joinTimeout, Path.GetFileName(_presentationPath), reasonForError); - // Force-kill the hung Excel process - if (_excelProcessId.HasValue) + // Force-kill the hung _pptApp process + if (_powerPointProcessId.HasValue) { try { - using var excelProcess = System.Diagnostics.Process.GetProcessById(_excelProcessId.Value); + using var pptProcess = System.Diagnostics.Process.GetProcessById(_powerPointProcessId.Value); _logger.LogWarning( - "[Thread {CallingThread}] Force-killing Excel process {ProcessId} for {FileName}", - callingThread, _excelProcessId.Value, Path.GetFileName(_workbookPath)); + "[Thread {CallingThread}] Force-killing _pptApp process {ProcessId} for {FileName}", + callingThread, _powerPointProcessId.Value, Path.GetFileName(_presentationPath)); - excelProcess.Kill(); - excelProcess.WaitForExit(5000); // Wait up to 5 seconds for process to die + pptProcess.Kill(); + pptProcess.WaitForExit(5000); // Wait up to 5 seconds for process to die _logger.LogInformation( - "[Thread {CallingThread}] Successfully force-killed Excel process {ProcessId}", - callingThread, _excelProcessId.Value); + "[Thread {CallingThread}] Successfully force-killed _pptApp process {ProcessId}", + callingThread, _powerPointProcessId.Value); // Now wait briefly for STA thread to exit after process killed if (_staThread.Join(TimeSpan.FromSeconds(5))) @@ -691,64 +683,64 @@ public void Dispose() catch (ArgumentException) { _logger.LogWarning( - "[Thread {CallingThread}] Excel process {ProcessId} not found (already exited?)", - callingThread, _excelProcessId.Value); + "[Thread {CallingThread}] _pptApp process {ProcessId} not found (already exited?)", + callingThread, _powerPointProcessId.Value); } catch (Exception ex) { _logger.LogError(ex, - "[Thread {CallingThread}] Failed to force-kill Excel process {ProcessId}", - callingThread, _excelProcessId.Value); + "[Thread {CallingThread}] Failed to force-kill _pptApp process {ProcessId}", + callingThread, _powerPointProcessId.Value); } } else { _logger.LogError( - "[Thread {CallingThread}] No Excel process ID captured - cannot force-kill. Process will leak.", + "[Thread {CallingThread}] No _pptApp process ID captured - cannot force-kill. Process will leak.", callingThread); } } } else { - _logger.LogDebug("[Thread {CallingThread}] STA thread was null or not alive for {FileName}", callingThread, Path.GetFileName(_workbookPath)); + _logger.LogDebug("[Thread {CallingThread}] STA thread was null or not alive for {FileName}", callingThread, Path.GetFileName(_presentationPath)); } - // Wait for Excel process to fully terminate to prevent CO_E_SERVER_EXEC_FAILURE - // on subsequent Activator.CreateInstance calls. excel.Quit() + COM release doesn't - // guarantee the EXCEL.EXE process has exited — rapid create/destroy cycles can fail. - if (_excelProcessId.HasValue) + // Wait for _pptApp process to fully terminate to prevent CO_E_SERVER_EXEC_FAILURE + // on subsequent Activator.CreateInstance calls. PowerPoint.Quit() + COM release doesn't + // guarantee the PowerPoint.EXE process has exited — rapid create/destroy cycles can fail. + if (_powerPointProcessId.HasValue) { try { - using var excelProc = System.Diagnostics.Process.GetProcessById(_excelProcessId.Value); - if (!excelProc.HasExited) + using var pptProcess = System.Diagnostics.Process.GetProcessById(_powerPointProcessId.Value); + if (!pptProcess.HasExited) { _logger.LogDebug( - "[Thread {CallingThread}] Waiting for Excel process {ProcessId} to exit for {FileName}", - callingThread, _excelProcessId.Value, Path.GetFileName(_workbookPath)); + "[Thread {CallingThread}] Waiting for _pptApp process {ProcessId} to exit for {FileName}", + callingThread, _powerPointProcessId.Value, Path.GetFileName(_presentationPath)); - if (!excelProc.WaitForExit(5000)) + if (!pptProcess.WaitForExit(5000)) { _logger.LogWarning( - "[Thread {CallingThread}] Excel process {ProcessId} did not exit within 5s for {FileName}. Force-killing to prevent zombie accumulation.", - callingThread, _excelProcessId.Value, Path.GetFileName(_workbookPath)); + "[Thread {CallingThread}] _pptApp process {ProcessId} did not exit within 5s for {FileName}. Force-killing to prevent zombie accumulation.", + callingThread, _powerPointProcessId.Value, Path.GetFileName(_presentationPath)); - // Force-kill: Excel was already told to Quit() and COM refs were released. + // Force-kill: _pptApp was already told to Quit() and COM refs were released. // A process still running after 5s is hung and will leak desktop resources. try { - excelProc.Kill(); - excelProc.WaitForExit(3000); + pptProcess.Kill(); + pptProcess.WaitForExit(3000); _logger.LogInformation( - "[Thread {CallingThread}] Force-killed lingering Excel process {ProcessId} for {FileName}", - callingThread, _excelProcessId.Value, Path.GetFileName(_workbookPath)); + "[Thread {CallingThread}] Force-killed lingering _pptApp process {ProcessId} for {FileName}", + callingThread, _powerPointProcessId.Value, Path.GetFileName(_presentationPath)); } catch (Exception killEx) { _logger.LogWarning(killEx, - "[Thread {CallingThread}] Failed to force-kill Excel process {ProcessId}", - callingThread, _excelProcessId.Value); + "[Thread {CallingThread}] Failed to force-kill _pptApp process {ProcessId}", + callingThread, _powerPointProcessId.Value); } } } @@ -764,10 +756,10 @@ public void Dispose() } // Dispose cancellation token source - _logger.LogDebug("[Thread {CallingThread}] Disposing CancellationTokenSource for {FileName}", callingThread, Path.GetFileName(_workbookPath)); + _logger.LogDebug("[Thread {CallingThread}] Disposing CancellationTokenSource for {FileName}", callingThread, Path.GetFileName(_presentationPath)); _shutdownCts.Dispose(); - _logger.LogDebug("[Thread {CallingThread}] Dispose COMPLETED for {FileName}", callingThread, Path.GetFileName(_workbookPath)); + _logger.LogDebug("[Thread {CallingThread}] Dispose COMPLETED for {FileName}", callingThread, Path.GetFileName(_presentationPath)); } } diff --git a/src/PptMcp.ComInterop/Session/PptContext.cs b/src/PptMcp.ComInterop/Session/PptContext.cs new file mode 100644 index 00000000..8f68811d --- /dev/null +++ b/src/PptMcp.ComInterop/Session/PptContext.cs @@ -0,0 +1,40 @@ +using PowerPoint = Microsoft.Office.Interop.PowerPoint; + +namespace PptMcp.ComInterop.Session; + +/// +/// Provides access to PowerPoint COM objects for operations. +/// Simplifies passing PowerPoint application and presentation to operations. +/// +public sealed class PptContext +{ + /// + /// Creates a new PptContext. + /// + /// Full path to the presentation + /// PowerPoint.Application COM object + /// PowerPoint.Presentation COM object + public PptContext(string presentationPath, PowerPoint.Application app, PowerPoint.Presentation presentation) + { + PresentationPath = presentationPath ?? throw new ArgumentNullException(nameof(presentationPath)); + App = app ?? throw new ArgumentNullException(nameof(app)); + Presentation = presentation ?? throw new ArgumentNullException(nameof(presentation)); + } + + /// + /// Gets the full path to the presentation. + /// + public string PresentationPath { get; } + + /// + /// Gets the PowerPoint.Application COM object. + /// + public PowerPoint.Application App { get; } + + /// + /// Gets the PowerPoint.Presentation COM object. + /// + public PowerPoint.Presentation Presentation { get; } +} + + diff --git a/src/PptMcp.ComInterop/Session/PptSession.cs b/src/PptMcp.ComInterop/Session/PptSession.cs new file mode 100644 index 00000000..8f33c043 --- /dev/null +++ b/src/PptMcp.ComInterop/Session/PptSession.cs @@ -0,0 +1,194 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; +using Ppt = Microsoft.Office.Interop.PowerPoint; + +namespace PptMcp.ComInterop.Session; + +/// +/// Main entry point for PowerPoint COM interop operations using batch pattern. +/// All operations execute on dedicated STA threads with proper COM cleanup. +/// +public static class PptSession +{ + /// + /// Global lock to serialize file creation operations. + /// Prevents resource exhaustion from parallel CreateNew() calls. + /// Each CreateNew() spawns a temporary PowerPoint instance - must be sequential. + /// + private static readonly SemaphoreSlim _createFileLock = new(1, 1); + + /// + /// Begins a batch of PowerPoint operations against one or more Presentation instances. + /// The PowerPoint instance remains open until the batch is disposed, enabling multiple operations + /// without incurring PowerPoint startup/shutdown overhead. + /// + /// Paths to PowerPoint files. First file is the primary Presentation. + /// IPptBatch for executing multiple operations + [SuppressMessage("Interoperability", "CA1416:Validate platform compatibility")] + public static IPptBatch BeginBatch(params string[] filePaths) + => BeginBatch(show: false, operationTimeout: null, filePaths); + + /// + /// Begins a batch of PowerPoint operations against one or more Presentation instances with optional UI visibility. + /// + /// Whether to show the PowerPoint window (default: false for background automation). + /// Maximum time for any single operation (default: 5 minutes). + /// Paths to PowerPoint files. First file is the primary Presentation. + /// IPptBatch for executing multiple operations + [SuppressMessage("Interoperability", "CA1416:Validate platform compatibility")] + public static IPptBatch BeginBatch( + bool show, + TimeSpan? operationTimeout, + params string[] filePaths) + { + if (filePaths == null || filePaths.Length == 0) + throw new ArgumentException("At least one file path is required", nameof(filePaths)); + + string[] fullPaths = new string[filePaths.Length]; + for (int i = 0; i < filePaths.Length; i++) + { + string fullPath = Path.GetFullPath(filePaths[i]); + + if (!File.Exists(fullPath)) + { + throw new FileNotFoundException($"PowerPoint file not found: {fullPath}. To create a new file, use the 'create' action instead of 'open'.", fullPath); + } + + string extension = Path.GetExtension(fullPath).ToLowerInvariant(); + if (extension is not (".pptx" or ".pptm" or ".ppt")) + { + throw new ArgumentException($"Invalid file extension '{extension}'. Only PowerPoint files (.pptx, .pptm, .ppt) are supported."); + } + + fullPaths[i] = fullPath; + } + + return new PptBatch(fullPaths, logger: null, show: show, operationTimeout: operationTimeout); + } + + /// + /// Creates a new PowerPoint Presentation at the specified path with a synchronous COM operation. + /// + [SuppressMessage("Interoperability", "CA1416:Validate platform compatibility")] + public static T CreateNew( + string filePath, + bool isMacroEnabled, + Func operation, + CancellationToken cancellationToken = default) + { + if (!_createFileLock.Wait(TimeSpan.FromMinutes(2), cancellationToken)) + { + throw new TimeoutException("Timed out waiting for file creation lock. Another CreateNew operation may be stuck."); + } + try + { + string fullPath = Path.GetFullPath(filePath); + + if (fullPath.Length > 218) + { + throw new PathTooLongException( + $"File path exceeds PowerPoint's maximum length (~218 characters): {fullPath.Length} characters"); + } + + string? directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + CreatePresentationOnStaThread(fullPath, isMacroEnabled, cancellationToken); + + using var batch = BeginBatch(fullPath); + var result = batch.Execute(operation, cancellationToken); + return result; + } + finally + { + _createFileLock.Release(); + } + } + + private static void CreatePresentationOnStaThread(string fullPath, bool isMacroEnabled, CancellationToken cancellationToken) + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var thread = new Thread(() => + { + Ppt.Application? pptApp = null; + Ppt.Presentation? presentation = null; + + try + { + OleMessageFilter.Register(); + + var pptType = Type.GetTypeFromProgID("PowerPoint.Application"); + if (pptType == null) + { + throw new InvalidOperationException("PowerPoint is not installed or not properly registered."); + } + +#pragma warning disable IL2072 + pptApp = (Ppt.Application)Activator.CreateInstance(pptType)!; +#pragma warning restore IL2072 + + // PowerPoint COM does NOT allow Visible = msoFalse (0). + // Unlike Excel, PowerPoint throws "Hiding the application window is not allowed." + // Always set Visible = msoTrue, minimize window instead. + ((dynamic)pptApp).Visible = -1; // msoTrue — required by PowerPoint COM + ((dynamic)pptApp).DisplayAlerts = 0; // ppAlertsNone + ((dynamic)pptApp).WindowState = 2; // ppWindowMinimized + + presentation = ((dynamic)pptApp).Presentations.Add(); + + int fileFormat = isMacroEnabled + ? ComInteropConstants.PpSaveAsOpenXMLPresentationMacroEnabled + : ComInteropConstants.PpSaveAsOpenXMLPresentation; + ((dynamic)presentation).SaveAs(fullPath, fileFormat); + + completion.SetResult(); + } + catch (Exception ex) + { + completion.TrySetException(ex); + } + finally + { + try + { + presentation?.Close(); + } + catch { } + + if (pptApp != null) + { + try { pptApp.Quit(); } catch { } + try { Marshal.ReleaseComObject(pptApp); } catch { } + } + if (presentation != null) + { + try { Marshal.ReleaseComObject(presentation); } catch { } + } + + OleMessageFilter.Revoke(); + } + }) + { + IsBackground = true, + Name = $"PptCreate-{Path.GetFileName(fullPath)}" + }; + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + + if (!completion.Task.Wait(TimeSpan.FromSeconds(30), cancellationToken)) + { + throw new TimeoutException($"File creation timed out for '{Path.GetFileName(fullPath)}'. PowerPoint may be unresponsive."); + } + + thread.Join(TimeSpan.FromSeconds(10)); + } +} + + + + diff --git a/src/ExcelMcp.ComInterop/Session/ExcelShutdownService.cs b/src/PptMcp.ComInterop/Session/PptShutdownService.cs similarity index 52% rename from src/ExcelMcp.ComInterop/Session/ExcelShutdownService.cs rename to src/PptMcp.ComInterop/Session/PptShutdownService.cs index c0a03cb8..0a76ab49 100644 --- a/src/ExcelMcp.ComInterop/Session/ExcelShutdownService.cs +++ b/src/PptMcp.ComInterop/Session/PptShutdownService.cs @@ -3,33 +3,33 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Polly; -using Excel = Microsoft.Office.Interop.Excel; +using PowerPoint = Microsoft.Office.Interop.PowerPoint; -namespace Sbroenne.ExcelMcp.ComInterop.Session; +namespace PptMcp.ComInterop.Session; /// -/// Centralized service for Excel workbook close and application quit operations. +/// Centralized service for PowerPoint presentation close and application quit operations. /// Implements resilient shutdown with exponential backoff for COM busy conditions. /// -public static class ExcelShutdownService +public static class PptShutdownService { - private static readonly ResiliencePipeline _quitPipeline = ResiliencePipelines.CreateExcelQuitPipeline(); + private static readonly ResiliencePipeline _quitPipeline = ResiliencePipelines.CreatePowerPointQuitPipeline(); /// - /// Saves an Excel workbook on the calling STA thread. - /// Must be called from within ExcelBatch.Execute() so the Save() COM call + /// Saves a PowerPoint presentation on the calling STA thread. + /// Must be called from within PptBatch.Execute() so the Save() COM call /// runs on the correct STA thread. Timeout protection is provided by the surrounding - /// ExcelBatch.Execute() operation timeout and the Dispose() force-kill chain. + /// PptBatch.Execute() operation timeout and the Dispose() force-kill chain. /// - /// Excel workbook COM object to save + /// PowerPoint presentation COM object to save /// File name for diagnostic messages (optional) /// Logger for diagnostic output (optional) /// Cancellation token checked before Save() is invoked /// Cancellation was requested before save started /// Save failed due to COM error /// Save failed due to unexpected error - public static void SaveWorkbookWithTimeout( - Excel.Workbook workbook, + public static void SavePresentationWithTimeout( + PowerPoint.Presentation presentation, string? fileName = null, ILogger? logger = null, CancellationToken cancellationToken = default) @@ -40,19 +40,13 @@ public static void SaveWorkbookWithTimeout( // Honour any cancellation request before we start the potentially slow COM call cancellationToken.ThrowIfCancellationRequested(); - logger.LogDebug("Saving workbook {FileName}", fileName); + logger.LogDebug("Saving presentation {FileName}", fileName); try { - // Call Save() directly on the STA thread. - // Previously this used Task.Run(() => workbook.Save()) which marshalled the COM call - // from the STA thread to an MTA thread-pool thread — crossing COM apartment boundaries - // and risking RPC_E_WRONG_THREAD or deadlocks (GitHub #482, Bug 1). - // This method is always called from inside ExcelBatch.Execute() which already runs - // on the dedicated STA thread, so a direct call is correct and safe. - workbook.Save(); - - logger.LogDebug("Workbook {FileName} saved successfully", fileName); + presentation.Save(); + + logger.LogDebug("Presentation {FileName} saved successfully", fileName); } catch (COMException ex) { @@ -64,7 +58,7 @@ public static void SaveWorkbookWithTimeout( unchecked((int)0x800AC472) => $"Cannot save '{fileName}'. " + "The file is locked for editing by another user or process.", - _ => $"Failed to save workbook '{fileName}': {ex.Message}" + _ => $"Failed to save presentation '{fileName}': {ex.Message}" }; logger.LogError(ex, "Save failed for {FileName} (HResult: 0x{HResult:X8})", fileName, ex.HResult); @@ -74,30 +68,28 @@ public static void SaveWorkbookWithTimeout( } /// - /// Closes a workbook and quits the Excel application with resilient retry logic. - /// Handles save semantics, workbook close, COM object release, and resilient Quit with backoff. + /// Closes a presentation and quits the PowerPoint application with resilient retry logic. + /// Handles save semantics, presentation close, COM object release, and resilient Quit with backoff. /// - /// Excel workbook COM object (can be null) - /// Excel application COM object (can be null) + /// PowerPoint presentation COM object (can be null) + /// PowerPoint application COM object (can be null) /// True to save before closing, false to discard changes /// File path for diagnostic logging (optional) /// Logger for diagnostic output (optional) /// /// Shutdown Order: /// - /// If save=true: Call workbook.Save() - /// Close workbook with Close(save) - save param controls Excel's save prompt - /// Release workbook COM reference - /// Quit Excel application with exponential backoff retry (6 attempts, 200ms base delay) - /// Release Excel COM reference - /// Force GC collection to release final COM proxies + /// If save=true: Call presentation.Save() + /// Close presentation with Close() - discards unsaved changes if save=false + /// Release presentation COM reference + /// Quit PowerPoint application with exponential backoff retry (6 attempts, 200ms base delay) + /// Release PowerPoint COM reference /// /// Resilience: Retries Quit() on COM busy errors (RPC_E_SERVERCALL_RETRYLATER, RPC_E_CALL_REJECTED) - /// Timeout: No overall timeout - relies on retry exhaustion. Non-retriable errors bubble immediately. /// public static void CloseAndQuit( - Excel.Workbook? workbook, - Excel.Application? excel, + PowerPoint.Presentation? presentation, + PowerPoint.Application? powerPoint, bool save, string? filePath = null, ILogger? logger = null) @@ -110,54 +102,61 @@ public static void CloseAndQuit( try { // Step 1: Explicit save if requested (before Close call) - if (save && workbook != null) + if (save && presentation != null) { - SaveWorkbookWithTimeout(workbook, fileName, logger); + SavePresentationWithTimeout(presentation, fileName, logger); } - // Step 2: Close workbook - if (workbook != null) + // Step 2: Close presentation + if (presentation != null) { try { - logger.LogDebug("Closing workbook {FileName} (save={Save})", fileName, save); - workbook.Close(save); - logger.LogDebug("Workbook {FileName} closed successfully", fileName); + logger.LogDebug("Closing presentation {FileName} (save={Save})", fileName, save); + // Mark as "already saved" to suppress the save-changes dialog + // PowerPoint COM shows a modal dialog on Close() if there are unsaved changes, + // even with DisplayAlerts=ppAlertsNone. Setting Saved=true prevents this. + if (!save) + { + try { ((dynamic)presentation).Saved = -1; } // msoTrue + catch { /* best effort */ } + } + presentation.Close(); + logger.LogDebug("Presentation {FileName} closed successfully", fileName); } catch (COMException ex) { logger.LogWarning(ex, - "Failed to close workbook {FileName} (HResult: 0x{HResult:X8}) - continuing with cleanup", + "Failed to close presentation {FileName} (HResult: 0x{HResult:X8}) - continuing with cleanup", fileName, ex.HResult); } catch (MissingMemberException ex) { // COM proxy already disconnected (RPC_E_DISCONNECTED / 0x80010108) logger.LogWarning(ex, - "Workbook COM proxy was disconnected while calling Close for {FileName} - continuing with cleanup", + "Presentation COM proxy was disconnected while calling Close for {FileName} - continuing with cleanup", fileName); } finally { - // Step 3: Release workbook COM reference - Marshal.ReleaseComObject(workbook); - workbook = null; + // Step 3: Release presentation COM reference + Marshal.ReleaseComObject(presentation); + presentation = null; } } - // Step 4: Quit Excel application with resilient retry + overall timeout - if (excel != null) + // Step 4: Quit PowerPoint application with resilient retry + overall timeout + if (powerPoint != null) { int attemptNumber = 0; Exception? lastException = null; - // Outer timeout catches truly hung Excel (modal dialogs, deadlocks) - // Excel can take a long time to quit after saving large files or when antivirus is scanning - using var quitTimeout = new CancellationTokenSource(ComInteropConstants.ExcelQuitTimeout); + // Outer timeout catches truly hung PowerPoint (modal dialogs, deadlocks) + using var quitTimeout = new CancellationTokenSource(ComInteropConstants.PowerPointQuitTimeout); try { - logger.LogDebug("Attempting to quit Excel for {FileName} with resilient retry ({Timeout} timeout)", fileName, ComInteropConstants.ExcelQuitTimeout); + logger.LogDebug("Attempting to quit PowerPoint for {FileName} with resilient retry ({Timeout} timeout)", fileName, ComInteropConstants.PowerPointQuitTimeout); // Inner retry pipeline handles transient COM busy errors within the timeout _quitPipeline.Execute(cancellationToken => @@ -166,7 +165,7 @@ public static void CloseAndQuit( try { logger.LogDebug("Quit attempt {Attempt} for {FileName}", attemptNumber, fileName); - excel.Quit(); + powerPoint.Quit(); logger.LogDebug("Quit attempt {Attempt} succeeded for {FileName}", attemptNumber, fileName); } catch (COMException ex) @@ -179,70 +178,63 @@ public static void CloseAndQuit( } }, quitTimeout.Token); - logger.LogInformation("Excel quit succeeded for {FileName} after {Attempts} attempt(s) in {Elapsed}ms", + logger.LogInformation("PowerPoint quit succeeded for {FileName} after {Attempts} attempt(s) in {Elapsed}ms", fileName, attemptNumber, stopwatch.ElapsedMilliseconds); } catch (OperationCanceledException) when (quitTimeout.Token.IsCancellationRequested) { - // Overall timeout reached - Excel is truly hung + // Overall timeout reached - PowerPoint is truly hung logger.LogError( - "Excel quit TIMED OUT after {Timeout} for {FileName} (Attempts: {Attempts}). " + - "Excel is likely hung (modal dialog or deadlock). Proceeding with forced COM cleanup.", - ComInteropConstants.ExcelQuitTimeout, fileName, attemptNumber); - lastException = new TimeoutException($"Excel.Quit() timed out after {ComInteropConstants.ExcelQuitTimeout} for {fileName}"); + "PowerPoint quit TIMED OUT after {Timeout} for {FileName} (Attempts: {Attempts}). " + + "PowerPoint is likely hung (modal dialog or deadlock). Proceeding with forced COM cleanup.", + ComInteropConstants.PowerPointQuitTimeout, fileName, attemptNumber); + lastException = new TimeoutException($"PowerPoint.Quit() timed out after {ComInteropConstants.PowerPointQuitTimeout} for {fileName}"); } catch (COMException ex) when (ex.HResult == ResiliencePipelines.RPC_E_CALL_FAILED) { - // Fatal RPC connection failure - Excel is unreachable + // Fatal RPC connection failure - PowerPoint is unreachable logger.LogError(ex, - "Excel RPC connection FAILED (0x800706BE) for {FileName}. " + - "Excel is unreachable - this is a FATAL error that cannot be retried. " + - "Proceeding with forced COM cleanup. Excel process should be force-killed by caller.", + "PowerPoint RPC connection FAILED (0x800706BE) for {FileName}. " + + "PowerPoint is unreachable - this is a FATAL error that cannot be retried. " + + "Proceeding with forced COM cleanup. PowerPoint process should be force-killed by caller.", fileName); lastException = ex; - // Don't retry - RPC connection is dead, Excel is gone } catch (COMException ex) { // All retry attempts exhausted or non-retriable error logger.LogError(ex, - "Excel quit failed for {FileName} after {Attempts} attempt(s) (HResult: 0x{HResult:X8}, Elapsed: {Elapsed}ms) - proceeding with COM cleanup", + "PowerPoint quit failed for {FileName} after {Attempts} attempt(s) (HResult: 0x{HResult:X8}, Elapsed: {Elapsed}ms) - proceeding with COM cleanup", fileName, attemptNumber, ex.HResult, stopwatch.ElapsedMilliseconds); lastException = ex; } catch (MissingMemberException ex) { logger.LogWarning(ex, - "Excel COM proxy was disconnected while calling Quit for {FileName} - proceeding with COM cleanup", + "PowerPoint COM proxy was disconnected while calling Quit for {FileName} - proceeding with COM cleanup", fileName); lastException = ex; } finally { - // Step 5: Release Excel COM reference (even if Quit failed/timed out) - Marshal.ReleaseComObject(excel); - excel = null; + // Step 5: Release PowerPoint COM reference (even if Quit failed/timed out) + Marshal.ReleaseComObject(powerPoint); + powerPoint = null; } // Additional diagnostic if quit failed if (lastException != null) { logger.LogWarning( - "Excel quit unsuccessful for {FileName} (Elapsed: {Elapsed}s, Type: {ExceptionType}). " + - "COM cleanup completed. Process may leak if Excel remains hung.", + "PowerPoint quit unsuccessful for {FileName} (Elapsed: {Elapsed}s, Type: {ExceptionType}). " + + "COM cleanup completed. Process may leak if PowerPoint remains hung.", fileName, stopwatch.Elapsed.TotalSeconds, lastException.GetType().Name); } } } finally { - // Step 6: COM cleanup happens automatically via RCW finalizers - // Per Microsoft docs: "RCWs can be cleaned by the CLR without additional code" - // GC.Collect() is rarely needed and can decrease performance - // https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/induced - // https://learn.microsoft.com/en-us/dotnet/framework/performance/reliability-best-practices - - logger.LogDebug("Excel shutdown sequence completed for {FileName} in {Elapsed}ms", + logger.LogDebug("PowerPoint shutdown sequence completed for {FileName} in {Elapsed}ms", fileName, stopwatch.ElapsedMilliseconds); } } diff --git a/src/ExcelMcp.ComInterop/Session/ResiliencePipelines.cs b/src/PptMcp.ComInterop/Session/ResiliencePipelines.cs similarity index 86% rename from src/ExcelMcp.ComInterop/Session/ResiliencePipelines.cs rename to src/PptMcp.ComInterop/Session/ResiliencePipelines.cs index be2d0059..e4ddb796 100644 --- a/src/ExcelMcp.ComInterop/Session/ResiliencePipelines.cs +++ b/src/PptMcp.ComInterop/Session/ResiliencePipelines.cs @@ -2,10 +2,10 @@ using Polly; using Polly.Retry; -namespace Sbroenne.ExcelMcp.ComInterop.Session; +namespace PptMcp.ComInterop.Session; /// -/// Provides pre-configured resilience pipelines for Excel COM interop operations. +/// Provides pre-configured resilience pipelines for _pptApp COM interop operations. /// public static class ResiliencePipelines { @@ -22,19 +22,19 @@ public static class ResiliencePipelines public const int RPC_E_CALL_REJECTED = unchecked((int)0x80010001); // -2147418111 /// - /// RPC_E_CALL_FAILED - RPC connection failed. Excel is unreachable. - /// FATAL ERROR - Do not retry, Excel process must be force-killed. + /// RPC_E_CALL_FAILED - RPC connection failed. _pptApp is unreachable. + /// FATAL ERROR - Do not retry, _pptApp process must be force-killed. /// public const int RPC_E_CALL_FAILED = unchecked((int)0x800706BE); // -2147023170 /// - /// RPC_S_SERVER_UNAVAILABLE - The RPC server is unavailable. Excel process has died. + /// RPC_S_SERVER_UNAVAILABLE - The RPC server is unavailable. _pptApp process has died. /// FATAL ERROR - Do not retry, session must be cleaned up. /// public const int RPC_S_SERVER_UNAVAILABLE = unchecked((int)0x800706BA); // -2147023174 /// - /// CO_E_SERVER_EXEC_FAILURE - COM class factory failed to start the server (Excel). + /// CO_E_SERVER_EXEC_FAILURE - COM class factory failed to start the server (PowerPoint). /// Transient during session creation when system resources are constrained. /// public const int CO_E_SERVER_EXEC_FAILURE = unchecked((int)0x80080005); // -2146959355 @@ -66,8 +66,8 @@ public static class ResiliencePipelines AdditionalHResults: [DATA_MODEL_BUSY]); /// - /// Retry configuration for session creation (starting new Excel instances). - /// Handles CO_E_SERVER_EXEC_FAILURE when system can't launch Excel temporarily. + /// Retry configuration for session creation (starting new _pptApp instances). + /// Handles CO_E_SERVER_EXEC_FAILURE when system can't launch _pptApp temporarily. /// Also handles RPC_E_CALL_FAILED since a new process hasn't been contacted yet. /// private static readonly PipelineConfig SessionCreationConfig = new( @@ -80,11 +80,11 @@ public static class ResiliencePipelines #region Factory Methods /// - /// Creates a retry pipeline for Excel.Quit() operations. + /// Creates a retry pipeline for PowerPoint.Quit() operations. /// Handles transient COM busy conditions with exponential backoff + jitter. /// /// Configured resilience pipeline - public static ResiliencePipeline CreateExcelQuitPipeline() => CreatePipeline(DefaultComConfig); + public static ResiliencePipeline CreatePowerPointQuitPipeline() => CreatePipeline(DefaultComConfig); /// /// Creates a retry pipeline for Data Model operations (measures, relationships, tables). @@ -92,7 +92,7 @@ public static class ResiliencePipelines /// /// /// The 0x800AC472 error occurs intermittently when performing Data Model operations - /// on workbooks with active Power Pivot models. The operation typically succeeds on retry. + /// on Presentations with active Power Pivot models. The operation typically succeeds on retry. /// See GitHub Issue #315 for details. /// /// Configured resilience pipeline @@ -101,12 +101,12 @@ public static class ResiliencePipelines /// /// Creates a retry pipeline for session creation operations. /// Handles CO_E_SERVER_EXEC_FAILURE (0x80080005) and RPC_E_CALL_FAILED (0x800706BE) - /// which occur when the system cannot start a new Excel process temporarily. + /// which occur when the system cannot start a new _pptApp process temporarily. /// /// - /// Unlike mid-session pipelines where RPC_E_CALL_FAILED is fatal (Excel died), + /// Unlike mid-session pipelines where RPC_E_CALL_FAILED is fatal (_pptApp died), /// during session creation these errors are transient — the system may be temporarily - /// unable to launch a new Excel process due to resource constraints. + /// unable to launch a new _pptApp process due to resource constraints. /// /// Configured resilience pipeline public static ResiliencePipeline CreateSessionCreationPipeline() => CreateSessionPipeline(SessionCreationConfig); @@ -144,7 +144,7 @@ private static ResiliencePipeline CreatePipeline(PipelineConfig config) /// /// Creates a resilience pipeline for session creation. /// Unlike , this does NOT exclude RPC_E_CALL_FAILED as fatal - /// because during session creation there is no existing Excel process to have died. + /// because during session creation there is no existing _pptApp process to have died. /// private static ResiliencePipeline CreateSessionPipeline(PipelineConfig config) { diff --git a/src/ExcelMcp.ComInterop/Session/SessionManager.cs b/src/PptMcp.ComInterop/Session/SessionManager.cs similarity index 76% rename from src/ExcelMcp.ComInterop/Session/SessionManager.cs rename to src/PptMcp.ComInterop/Session/SessionManager.cs index ef80d618..053bb8fc 100644 --- a/src/ExcelMcp.ComInterop/Session/SessionManager.cs +++ b/src/PptMcp.ComInterop/Session/SessionManager.cs @@ -3,33 +3,30 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -namespace Sbroenne.ExcelMcp.ComInterop.Session; +namespace PptMcp.ComInterop.Session; /// -/// Manages active Excel sessions for the MCP server and CLI. -/// Maps user-facing sessionId to internal IExcelBatch instances. +/// Manages the active PowerPoint session for the MCP server and CLI. +/// Maps user-facing sessionId to internal IPptBatch instance. /// /// +/// CRITICAL: PowerPoint COM is single-instance. Unlike Excel, creating multiple +/// PowerPoint.Application objects shares the same POWERPNT.EXE process. Quitting one kills all. +/// Therefore, only ONE session can be active at a time. /// Concurrency Model: /// -/// Within-session operations are SERIAL: Each session queues operations on one STA thread -/// Between-session operations CAN be parallel: Different sessions = different Excel processes -/// Same-file prevention: Cannot open the same file in multiple sessions (matches Excel UI behavior) -/// -/// Resource Limits: -/// -/// Each session = one Excel.Application process (~50-100MB+ memory) -/// Recommended maximum: 3-5 concurrent sessions on typical desktop machines -/// Always close sessions promptly to free resources +/// Only ONE session at a time: PowerPoint COM is single-instance +/// Within-session operations are SERIAL: Operations queue on one STA thread +/// Close before opening another: Must close current session before opening a new file /// /// public sealed class SessionManager : IDisposable { - private readonly ConcurrentDictionary _activeSessions = new(); + private readonly ConcurrentDictionary _activeSessions = new(); private readonly ConcurrentDictionary _activeFilePaths = new(); private readonly ConcurrentDictionary _sessionFilePaths = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary _activeOperationCounts = new(); - private readonly ConcurrentDictionary _showExcelFlags = new(); + private readonly ConcurrentDictionary _showPowerPointFlags = new(); private readonly ConcurrentDictionary _sessionOrigins = new(); private readonly ConcurrentDictionary _sessionCreatedAt = new(); private readonly Polly.ResiliencePipeline _sessionCreationPipeline = ResiliencePipelines.CreateSessionCreationPipeline(); @@ -46,27 +43,32 @@ public SessionManager(ILogger? logger = null) } /// - /// Creates a new session for the specified Excel file. + /// Creates a new session for the specified PowerPoint file. /// - /// Path to the Excel file to open - /// Whether to show the Excel window (default: false for background automation) + /// Path to the PowerPoint file to open + /// Whether to show the PowerPoint window (default: false for background automation) /// Maximum time for any operation in this session (default: 5 minutes) /// Which client is creating this session (CLI or MCP) /// Unique session ID for this session /// File does not exist - /// Failed to create session or file already open in another session + /// Failed to create session, session already active, or file already open /// - /// Resource Impact: Creates a new Excel.Application process (~50-100MB+ memory). - /// Same-file prevention: Throws if file is already open in another session. - /// Concurrency: You can create multiple sessions for DIFFERENT files. Operations within each session execute serially. + /// Single-session only: PowerPoint COM is single-instance. Only one session can be active at a time. + /// Close the current session before opening another file. /// public string CreateSession(string filePath, bool show = false, TimeSpan? operationTimeout = null, SessionOrigin origin = SessionOrigin.Unknown) { ObjectDisposedException.ThrowIf(_disposed, this); + // CRITICAL: PowerPoint COM is single-instance — only one session at a time + if (!_activeSessions.IsEmpty) + { + throw new InvalidOperationException("PowerPoint COM is single-instance. Only one session can be active at a time. Close the current session before opening another file."); + } + if (!File.Exists(filePath)) { - throw new FileNotFoundException($"Excel file not found: {filePath}. To create a new file, use the 'create' action instead of 'open'.", filePath); + throw new FileNotFoundException($"PowerPoint file not found: {filePath}. To create a new file, use the 'create' action instead of 'open'.", filePath); } // Normalize file path for comparison @@ -75,18 +77,18 @@ public string CreateSession(string filePath, bool show = false, TimeSpan? operat // Check if file is already open in another session if (_activeFilePaths.ContainsKey(normalizedPath)) { - throw new InvalidOperationException($"File '{filePath}' is already open in another session. Excel cannot open the same file multiple times."); + throw new InvalidOperationException($"File '{filePath}' is already open in another session. PowerPoint cannot open the same file multiple times."); } // Generate unique session ID string sessionId = Guid.NewGuid().ToString("N"); - IExcelBatch? batch = null; + IPptBatch? batch = null; try { // Create batch session using Core API with retry for transient COM failures // (e.g., CO_E_SERVER_EXEC_FAILURE when system resources are constrained) - batch = _sessionCreationPipeline.Execute(() => ExcelSession.BeginBatch(show, operationTimeout, filePath)); + batch = _sessionCreationPipeline.Execute(() => PptSession.BeginBatch(show, operationTimeout, filePath)); // Store in active sessions if (!_activeSessions.TryAdd(sessionId, batch)) @@ -111,7 +113,7 @@ public string CreateSession(string filePath, bool show = false, TimeSpan? operat // Initialize operation counter and show flag _activeOperationCounts[sessionId] = 0; - _showExcelFlags[sessionId] = show; + _showPowerPointFlags[sessionId] = show; _sessionOrigins[sessionId] = origin; _sessionCreatedAt[sessionId] = DateTime.UtcNow; @@ -132,32 +134,38 @@ public string CreateSession(string filePath, bool show = false, TimeSpan? operat } /// - /// Creates a new Excel file and opens a session for it in one operation. - /// This is the preferred method for creating new workbooks with sessions. + /// Creates a new PowerPoint file and opens a session for it in one operation. + /// This is the preferred method for creating new Presentations with sessions. /// - /// Path for the new Excel file (.xlsx or .xlsm) - /// Whether to show the Excel window (default: false) + /// Path for the new PowerPoint file (.pptx or .pptm) + /// Whether to show the PowerPoint window (default: false) /// Maximum time for any operation in this session (default: 5 minutes) /// Which client is creating this session (CLI or MCP) /// Unique session ID for this session /// File already exists, or failed to create session /// Target directory does not exist /// - /// Single Excel Start: This method starts Excel only once, creating the file and session together. - /// File Format: Determined by extension - .xlsm creates macro-enabled workbook. + /// Single PowerPoint Start: This method starts PowerPoint only once, creating the file and session together. + /// File Format: Determined by extension - .pptm creates macro-enabled Presentation. /// Directory: Target directory must exist - will not be created automatically. /// public string CreateSessionForNewFile(string filePath, bool show = false, TimeSpan? operationTimeout = null, SessionOrigin origin = SessionOrigin.Unknown) { ObjectDisposedException.ThrowIf(_disposed, this); + // CRITICAL: PowerPoint COM is single-instance — only one session at a time + if (!_activeSessions.IsEmpty) + { + throw new InvalidOperationException("PowerPoint COM is single-instance. Only one session can be active at a time. Close the current session before creating a new file."); + } + string normalizedPath = Path.GetFullPath(filePath); // Validate extension string extension = Path.GetExtension(normalizedPath).ToLowerInvariant(); - if (extension is not (".xlsx" or ".xlsm")) + if (extension is not (".pptx" or ".pptm")) { - throw new ArgumentException($"Invalid file extension '{extension}'. Only .xlsx and .xlsm are supported."); + throw new ArgumentException($"Invalid file extension '{extension}'. Only .pptx and .pptm are supported."); } // Check if file already exists @@ -174,13 +182,13 @@ public string CreateSessionForNewFile(string filePath, bool show = false, TimeSp // Generate unique session ID string sessionId = Guid.NewGuid().ToString("N"); - bool isMacroEnabled = extension == ".xlsm"; + bool isMacroEnabled = extension == ".pptm"; - ExcelBatch? batch = null; + PptBatch? batch = null; try { - // Create new workbook and keep session open with retry for transient COM failures - batch = _sessionCreationPipeline.Execute(() => ExcelBatch.CreateNewWorkbook(normalizedPath, isMacroEnabled, logger: null, show: show, operationTimeout: operationTimeout)); + // Create new Presentation and keep session open with retry for transient COM failures + batch = _sessionCreationPipeline.Execute(() => PptBatch.CreateNewPresentation(normalizedPath, isMacroEnabled, logger: null, show: show, operationTimeout: operationTimeout)); // Store in active sessions if (!_activeSessions.TryAdd(sessionId, batch)) @@ -204,7 +212,7 @@ public string CreateSessionForNewFile(string filePath, bool show = false, TimeSp // Initialize operation counter and show flag _activeOperationCounts[sessionId] = 0; - _showExcelFlags[sessionId] = show; + _showPowerPointFlags[sessionId] = show; _sessionOrigins[sessionId] = origin; _sessionCreatedAt[sessionId] = DateTime.UtcNow; @@ -228,11 +236,11 @@ public string CreateSessionForNewFile(string filePath, bool show = false, TimeSp /// /// Gets an active session by ID. - /// If the session exists but Excel has died, it is automatically cleaned up and null is returned. + /// If the session exists but PowerPoint has died, it is automatically cleaned up and null is returned. /// /// Session ID returned from CreateSession - /// IExcelBatch instance, or null if session not found or Excel process is dead - public IExcelBatch? GetSession(string sessionId) + /// IPptBatch instance, or null if session not found or PowerPoint process is dead + public IPptBatch? GetSession(string sessionId) { ObjectDisposedException.ThrowIf(_disposed, this); @@ -246,10 +254,10 @@ public string CreateSessionForNewFile(string filePath, bool show = false, TimeSp return null; } - // Check if Excel process is still alive - if (!batch.IsExcelProcessAlive()) + // Check if PowerPoint process is still alive + if (!batch.IsPowerPointProcessAlive()) { - _logger?.LogWarning("Session {SessionId} has dead Excel process, auto-cleaning up", sessionId); + _logger?.LogWarning("Session {SessionId} has dead PowerPoint process, auto-cleaning up", sessionId); CleanupDeadSession(sessionId, batch); return null; } @@ -258,10 +266,10 @@ public string CreateSessionForNewFile(string filePath, bool show = false, TimeSp } /// - /// Cleans up a session whose Excel process has died. + /// Cleans up a session whose PowerPoint process has died. /// This removes all tracking data and disposes the batch (best effort). /// - private void CleanupDeadSession(string sessionId, IExcelBatch batch) + private void CleanupDeadSession(string sessionId, IPptBatch batch) { // Remove from active sessions _activeSessions.TryRemove(sessionId, out _); @@ -282,7 +290,7 @@ private void CleanupDeadSession(string sessionId, IExcelBatch batch) // Clean up operation tracking data _activeOperationCounts.TryRemove(sessionId, out _); - _showExcelFlags.TryRemove(sessionId, out _); + _showPowerPointFlags.TryRemove(sessionId, out _); // Clean up session origin tracking data _sessionOrigins.TryRemove(sessionId, out _); @@ -333,28 +341,28 @@ public int GetActiveOperationCount(string sessionId) } /// - /// Gets whether Excel is visible for a session. + /// Gets whether PowerPoint is visible for a session. /// /// Session ID - /// True if Excel is visible for this session - public bool IsExcelVisible(string sessionId) + /// True if PowerPoint is visible for this session + public bool IsPowerPointVisible(string sessionId) { if (string.IsNullOrWhiteSpace(sessionId)) return false; - return _showExcelFlags.TryGetValue(sessionId, out var visible) && visible; + return _showPowerPointFlags.TryGetValue(sessionId, out var visible) && visible; } /// /// Updates the visibility flag for a session. - /// Called by window management commands when Excel visibility changes mid-session. + /// Called by window management commands when PowerPoint visibility changes mid-session. /// /// Session ID /// New visibility state /// True if session was found and flag updated, false if session not found - public bool SetExcelVisible(string sessionId, bool visible) + public bool SetPowerPointVisible(string sessionId, bool visible) { if (string.IsNullOrWhiteSpace(sessionId)) return false; if (!_activeSessions.ContainsKey(sessionId)) return false; - _showExcelFlags[sessionId] = visible; + _showPowerPointFlags[sessionId] = visible; return true; } @@ -379,7 +387,7 @@ public CloseValidationResult ValidateClose(string sessionId) } var activeOps = GetActiveOperationCount(sessionId); - var isVisible = IsExcelVisible(sessionId); + var isVisible = IsPowerPointVisible(sessionId); if (activeOps > 0) { @@ -469,7 +477,7 @@ private bool CloseSessionSync(string sessionId) // Clean up operation tracking data _activeOperationCounts.TryRemove(sessionId, out _); - _showExcelFlags.TryRemove(sessionId, out _); + _showPowerPointFlags.TryRemove(sessionId, out _); // Clean up session origin tracking data _sessionOrigins.TryRemove(sessionId, out _); @@ -494,23 +502,23 @@ private bool CloseSessionSync(string sessionId) public int ActiveSessionCount => _activeSessions.Count; /// - /// Checks if the Excel process for a session is still alive. - /// If the session exists but Excel has died, it is automatically cleaned up. + /// Checks if the PowerPoint process for a session is still alive. + /// If the session exists but PowerPoint has died, it is automatically cleaned up. /// /// Session ID - /// True if session exists and Excel process is alive, false otherwise + /// True if session exists and PowerPoint process is alive, false otherwise public bool IsSessionAlive(string sessionId) { if (string.IsNullOrWhiteSpace(sessionId)) return false; if (!_activeSessions.TryGetValue(sessionId, out var batch)) return false; - if (batch.IsExcelProcessAlive()) + if (batch.IsPowerPointProcessAlive()) { return true; } // Auto-cleanup dead session - _logger?.LogWarning("Session {SessionId} has dead Excel process, auto-cleaning up during IsSessionAlive check", sessionId); + _logger?.LogWarning("Session {SessionId} has dead PowerPoint process, auto-cleaning up during IsSessionAlive check", sessionId); CleanupDeadSession(sessionId, batch); return false; } @@ -522,15 +530,15 @@ public bool IsSessionAlive(string sessionId) public IEnumerable ActiveSessionIds => _activeSessions.Keys.ToList(); /// - /// Returns a snapshot of active sessions with associated workbook paths. - /// Dead sessions (where Excel process has died) are automatically cleaned up and excluded. + /// Returns a snapshot of active sessions with associated Presentation paths. + /// Dead sessions (where PowerPoint process has died) are automatically cleaned up and excluded. /// public IReadOnlyList GetActiveSessions() { ObjectDisposedException.ThrowIf(_disposed, this); var snapshot = new List(_sessionFilePaths.Count); - var deadSessions = new List<(string sessionId, IExcelBatch batch)>(); + var deadSessions = new List<(string sessionId, IPptBatch batch)>(); foreach (var kvp in _sessionFilePaths) { @@ -539,7 +547,7 @@ public IReadOnlyList GetActiveSessions() // Check if session is still alive if (_activeSessions.TryGetValue(sessionId, out var batch)) { - if (batch.IsExcelProcessAlive()) + if (batch.IsPowerPointProcessAlive()) { // Get origin and createdAt metadata (defaults for legacy sessions) _sessionOrigins.TryGetValue(sessionId, out var origin); @@ -559,7 +567,7 @@ public IReadOnlyList GetActiveSessions() // Clean up dead sessions after iteration foreach (var (sessionId, batch) in deadSessions) { - _logger?.LogWarning("Session {SessionId} has dead Excel process, auto-cleaning up during GetActiveSessions", sessionId); + _logger?.LogWarning("Session {SessionId} has dead PowerPoint process, auto-cleaning up during GetActiveSessions", sessionId); CleanupDeadSession(sessionId, batch); } @@ -567,7 +575,7 @@ public IReadOnlyList GetActiveSessions() } /// - /// Attempts to get the workbook path associated with a session ID. + /// Attempts to get the Presentation path associated with a session ID. /// public bool TryGetFilePath(string sessionId, [NotNullWhen(true)] out string? filePath) { @@ -589,7 +597,7 @@ public bool TryGetFilePath(string sessionId, [NotNullWhen(true)] out string? fil /// CRITICAL: Sessions are auto-saved before disposal to prevent silent data loss /// when the service shuts down (e.g., MCP client disconnect, process exit). /// CRITICAL: Sessions are disposed SEQUENTIALLY to avoid COM threading issues. - /// Excel COM objects must be disposed on their STA threads. Parallel disposal causes deadlocks. + /// PowerPoint COM objects must be disposed on their STA threads. Parallel disposal causes deadlocks. /// public void Dispose() { @@ -601,7 +609,7 @@ public void Dispose() _disposed = true; // Close all active sessions SEQUENTIALLY to avoid COM threading issues - // Excel COM objects must be disposed on their STA threads, parallel disposal causes deadlocks + // PowerPoint COM objects must be disposed on their STA threads, parallel disposal causes deadlocks var sessions = _activeSessions.Values.ToList(); _activeSessions.Clear(); _activeFilePaths.Clear(); @@ -612,24 +620,24 @@ public void Dispose() // Auto-save before disposal to prevent silent data loss. // This protects against the common scenario where the MCP client disconnects // or the service process exits, which would otherwise discard all unsaved work. - if (session.IsExcelProcessAlive()) + if (session.IsPowerPointProcessAlive()) { try { using var saveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); session.Save(saveTimeout.Token); - _logger.LogInformation("Auto-saved session for {Path} before shutdown", session.WorkbookPath); + _logger.LogInformation("Auto-saved session for {Path} before shutdown", session.PresentationPath); } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to auto-save session for {Path} before shutdown (changes may be lost)", session.WorkbookPath); + _logger.LogWarning(ex, "Failed to auto-save session for {Path} before shutdown (changes may be lost)", session.PresentationPath); } } try { - // Dispose sequentially - ExcelBatch.Dispose() handles its own Excel cleanup - // via ExcelShutdownService with proper timeouts and retry logic + // Dispose sequentially - PptBatch.Dispose() handles its own PowerPoint cleanup + // via PptShutdownService with proper timeouts and retry logic session.Dispose(); } catch (Exception) @@ -641,10 +649,10 @@ public void Dispose() } /// -/// Represents a snapshot of an active Excel session managed by . +/// Represents a snapshot of an active PowerPoint session managed by . /// /// Public session identifier shared with clients. -/// Normalized workbook path associated with the session. +/// Normalized Presentation path associated with the session. /// Which client created this session (CLI or MCP). /// When the session was created. public sealed record SessionDescriptor( @@ -672,12 +680,12 @@ public enum SessionOrigin /// Result of validating whether a session can be closed. /// /// Whether the session was found. -/// Whether Excel is visible (show=true). +/// Whether PowerPoint is visible (show=true). /// Number of operations currently running. /// Reason why close is blocked, or null if close is allowed. public sealed record CloseValidationResult( bool SessionExists, - bool IsExcelVisible, + bool IsPowerPointVisible, int ActiveOperationCount, string? BlockingReason) { diff --git a/src/ExcelMcp.Core/Attributes/FileOrValueAttribute.cs b/src/PptMcp.Core/Attributes/FileOrValueAttribute.cs similarity index 96% rename from src/ExcelMcp.Core/Attributes/FileOrValueAttribute.cs rename to src/PptMcp.Core/Attributes/FileOrValueAttribute.cs index 286eba03..26475cc9 100644 --- a/src/ExcelMcp.Core/Attributes/FileOrValueAttribute.cs +++ b/src/PptMcp.Core/Attributes/FileOrValueAttribute.cs @@ -1,4 +1,4 @@ -namespace Sbroenne.ExcelMcp.Core.Attributes; +namespace PptMcp.Core.Attributes; /// /// Indicates that this parameter can be provided either as a direct value or via a file path. diff --git a/src/ExcelMcp.Core/Attributes/FromStringAttribute.cs b/src/PptMcp.Core/Attributes/FromStringAttribute.cs similarity index 96% rename from src/ExcelMcp.Core/Attributes/FromStringAttribute.cs rename to src/PptMcp.Core/Attributes/FromStringAttribute.cs index c44e7a04..fdb5e0c9 100644 --- a/src/ExcelMcp.Core/Attributes/FromStringAttribute.cs +++ b/src/PptMcp.Core/Attributes/FromStringAttribute.cs @@ -1,4 +1,4 @@ -namespace Sbroenne.ExcelMcp.Core.Attributes; +namespace PptMcp.Core.Attributes; /// /// Indicates that this enum parameter should be exposed as a string in MCP/CLI. diff --git a/src/ExcelMcp.Core/Attributes/McpToolAttribute.cs b/src/PptMcp.Core/Attributes/McpToolAttribute.cs similarity index 97% rename from src/ExcelMcp.Core/Attributes/McpToolAttribute.cs rename to src/PptMcp.Core/Attributes/McpToolAttribute.cs index b11bd6a5..4713c039 100644 --- a/src/ExcelMcp.Core/Attributes/McpToolAttribute.cs +++ b/src/PptMcp.Core/Attributes/McpToolAttribute.cs @@ -1,4 +1,4 @@ -namespace Sbroenne.ExcelMcp.Core.Attributes; +namespace PptMcp.Core.Attributes; /// /// Specifies which MCP tool exposes this interface or method. diff --git a/src/ExcelMcp.Core/Attributes/NoSessionAttribute.cs b/src/PptMcp.Core/Attributes/NoSessionAttribute.cs similarity index 92% rename from src/ExcelMcp.Core/Attributes/NoSessionAttribute.cs rename to src/PptMcp.Core/Attributes/NoSessionAttribute.cs index 5e77522b..6803d139 100644 --- a/src/ExcelMcp.Core/Attributes/NoSessionAttribute.cs +++ b/src/PptMcp.Core/Attributes/NoSessionAttribute.cs @@ -1,4 +1,4 @@ -namespace Sbroenne.ExcelMcp.Core.Attributes; +namespace PptMcp.Core.Attributes; /// /// Marks an interface as not requiring a session. diff --git a/src/ExcelMcp.Core/Attributes/RequiredParameterAttribute.cs b/src/PptMcp.Core/Attributes/RequiredParameterAttribute.cs similarity index 90% rename from src/ExcelMcp.Core/Attributes/RequiredParameterAttribute.cs rename to src/PptMcp.Core/Attributes/RequiredParameterAttribute.cs index 5f65decd..5efd0ba4 100644 --- a/src/ExcelMcp.Core/Attributes/RequiredParameterAttribute.cs +++ b/src/PptMcp.Core/Attributes/RequiredParameterAttribute.cs @@ -1,4 +1,4 @@ -namespace Sbroenne.ExcelMcp.Core.Attributes; +namespace PptMcp.Core.Attributes; /// /// Indicates that this parameter is required for the action. diff --git a/src/ExcelMcp.Core/Attributes/ServiceActionAttribute.cs b/src/PptMcp.Core/Attributes/ServiceActionAttribute.cs similarity index 95% rename from src/ExcelMcp.Core/Attributes/ServiceActionAttribute.cs rename to src/PptMcp.Core/Attributes/ServiceActionAttribute.cs index 8c43ba82..b87ea935 100644 --- a/src/ExcelMcp.Core/Attributes/ServiceActionAttribute.cs +++ b/src/PptMcp.Core/Attributes/ServiceActionAttribute.cs @@ -1,4 +1,4 @@ -namespace Sbroenne.ExcelMcp.Core.Attributes; +namespace PptMcp.Core.Attributes; /// /// Overrides the default action name derived from method name. diff --git a/src/ExcelMcp.Core/Attributes/ServiceCategoryAttribute.cs b/src/PptMcp.Core/Attributes/ServiceCategoryAttribute.cs similarity index 96% rename from src/ExcelMcp.Core/Attributes/ServiceCategoryAttribute.cs rename to src/PptMcp.Core/Attributes/ServiceCategoryAttribute.cs index 7e471b97..65a37b62 100644 --- a/src/ExcelMcp.Core/Attributes/ServiceCategoryAttribute.cs +++ b/src/PptMcp.Core/Attributes/ServiceCategoryAttribute.cs @@ -1,4 +1,4 @@ -namespace Sbroenne.ExcelMcp.Core.Attributes; +namespace PptMcp.Core.Attributes; /// /// Marks an interface as a service category for code generation. diff --git a/src/PptMcp.Core/Commands/Accessibility/AccessibilityCommands.cs b/src/PptMcp.Core/Commands/Accessibility/AccessibilityCommands.cs new file mode 100644 index 00000000..9b0828bb --- /dev/null +++ b/src/PptMcp.Core/Commands/Accessibility/AccessibilityCommands.cs @@ -0,0 +1,322 @@ +using PptMcp.ComInterop; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Commands.Slide; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Accessibility; + +public class AccessibilityCommands : IAccessibilityCommands +{ + public AccessibilityAuditResult Audit(IPptBatch batch) + { + return batch.Execute((ctx, ct) => + { + var result = new AccessibilityAuditResult + { + Success = true, + FilePath = ctx.PresentationPath + }; + + dynamic pres = ctx.Presentation; + dynamic slides = pres.Slides; + try + { + int slideCount = (int)slides.Count; + result.TotalSlides = slideCount; + + for (int si = 1; si <= slideCount; si++) + { + dynamic slide = slides.Item(si); + try + { + AuditSlide(slide, si, result.Issues); + } + finally + { + ComUtilities.Release(ref slide!); + } + } + + result.IssueCount = result.Issues.Count; + + if (result.Issues.Count == 0) + { + result.Message = "No accessibility issues found."; + } + else + { + result.Message = $"Found {result.Issues.Count} accessibility issue(s) across {slideCount} slide(s)."; + } + + return result; + } + finally + { + ComUtilities.Release(ref slides!); + } + }); + } + + public ReadingOrderResult GetReadingOrder(IPptBatch batch, int slideIndex) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + try + { + var result = new ReadingOrderResult + { + Success = true, + FilePath = ctx.PresentationPath, + SlideIndex = slideIndex + }; + + dynamic shapes = slide.Shapes; + try + { + int count = (int)shapes.Count; + var entries = new List(count); + + for (int i = 1; i <= count; i++) + { + dynamic shape = shapes.Item(i); + try + { + int shapeType = Convert.ToInt32(shape.Type); + entries.Add(new ReadingOrderEntry + { + ShapeName = shape.Name?.ToString() ?? "", + ShapeType = ShapeHelpers.GetShapeTypeName(shapeType), + ZOrderPosition = (int)shape.ZOrderPosition + }); + } + finally + { + ComUtilities.Release(ref shape!); + } + } + + // Sort by ZOrderPosition (reading order) + entries.Sort((a, b) => a.ZOrderPosition.CompareTo(b.ZOrderPosition)); + + for (int i = 0; i < entries.Count; i++) + { + entries[i].Position = i + 1; + } + + result.Shapes = entries; + return result; + } + finally + { + ComUtilities.Release(ref shapes!); + } + } + finally + { + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult SetReadingOrder(IPptBatch batch, int slideIndex, string shapeNames) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + try + { + var names = shapeNames + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + + if (names.Count == 0) + throw new ArgumentException("shapeNames must contain at least one shape name."); + + dynamic shapes = slide.Shapes; + try + { + // Send each shape to back in reverse order so the first name ends up with lowest ZOrder + // msoSendToBack = 1 + for (int i = names.Count - 1; i >= 0; i--) + { + dynamic shape = shapes.Item(names[i]); + try + { + shape.ZOrder(1); // msoSendToBack + } + finally + { + ComUtilities.Release(ref shape!); + } + } + + // Now bring each forward in order so they stack correctly + // msoSendToBack already placed them; now bring them to front in order + // to get the desired reading order: first name = lowest ZOrder + for (int i = 0; i < names.Count; i++) + { + dynamic shape = shapes.Item(names[i]); + try + { + shape.ZOrder(0); // msoBringToFront + } + finally + { + ComUtilities.Release(ref shape!); + } + } + + return new OperationResult + { + Success = true, + Action = "set-reading-order", + Message = $"Set reading order for {names.Count} shape(s) on slide {slideIndex}.", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref shapes!); + } + } + finally + { + ComUtilities.Release(ref slide!); + } + }); + } + + private static void AuditSlide(dynamic slide, int slideIndex, List issues) + { + bool hasTitle = false; + + // Check placeholders for title + dynamic? placeholders = null; + try + { + placeholders = slide.Shapes.Placeholders; + int phCount = (int)placeholders.Count; + for (int pi = 1; pi <= phCount; pi++) + { + dynamic ph = placeholders.Item(pi); + try + { + int phType = Convert.ToInt32(ph.PlaceholderFormat.Type); + // ppPlaceholderTitle = 1, ppPlaceholderCenterTitle = 3 + if (phType == 1 || phType == 3) + { + hasTitle = true; + + // Check if title placeholder has text + bool hasText = false; + try + { + if (Convert.ToInt32(ph.HasTextFrame) != 0) + { + string? text = ph.TextFrame.TextRange.Text?.ToString(); + hasText = !string.IsNullOrWhiteSpace(text); + } + } + catch { } + + if (!hasText) + { + issues.Add(new AccessibilityIssue + { + SlideIndex = slideIndex, + IssueType = "EmptyTitlePlaceholder", + ShapeName = ph.Name?.ToString(), + Description = "Title placeholder exists but has no text." + }); + } + } + else + { + // Check other placeholders for empty text + try + { + if (Convert.ToInt32(ph.HasTextFrame) != 0) + { + string? text = ph.TextFrame.TextRange.Text?.ToString(); + if (string.IsNullOrWhiteSpace(text)) + { + issues.Add(new AccessibilityIssue + { + SlideIndex = slideIndex, + IssueType = "EmptyPlaceholder", + ShapeName = ph.Name?.ToString(), + Description = "Placeholder has no text content." + }); + } + } + } + catch { } + } + } + finally + { + ComUtilities.Release(ref ph!); + } + } + } + catch { } + finally + { + if (placeholders != null) ComUtilities.Release(ref placeholders!); + } + + if (!hasTitle) + { + issues.Add(new AccessibilityIssue + { + SlideIndex = slideIndex, + IssueType = "MissingTitle", + Description = "Slide has no title placeholder." + }); + } + + // Check all shapes for missing alt text + dynamic? shapes = null; + try + { + shapes = slide.Shapes; + int shapeCount = (int)shapes.Count; + for (int i = 1; i <= shapeCount; i++) + { + dynamic shape = shapes.Item(i); + try + { + int shapeType = Convert.ToInt32(shape.Type); + // Skip placeholders (already checked), comments, and lines + if (shapeType == 14 || shapeType == 4 || shapeType == 9) + continue; + + string? altText = null; + try { altText = shape.AlternativeText?.ToString(); } catch { } + + if (string.IsNullOrWhiteSpace(altText)) + { + string shapeName = shape.Name?.ToString() ?? ""; + issues.Add(new AccessibilityIssue + { + SlideIndex = slideIndex, + IssueType = "MissingAltText", + ShapeName = shapeName, + Description = $"Shape '{shapeName}' ({ShapeHelpers.GetShapeTypeName(shapeType)}) has no alternative text." + }); + } + } + finally + { + ComUtilities.Release(ref shape!); + } + } + } + catch { } + finally + { + if (shapes != null) ComUtilities.Release(ref shapes!); + } + } +} diff --git a/src/PptMcp.Core/Commands/Accessibility/IAccessibilityCommands.cs b/src/PptMcp.Core/Commands/Accessibility/IAccessibilityCommands.cs new file mode 100644 index 00000000..bbbc0dfe --- /dev/null +++ b/src/PptMcp.Core/Commands/Accessibility/IAccessibilityCommands.cs @@ -0,0 +1,36 @@ +using PptMcp.ComInterop.Session; +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Accessibility; + +/// +/// Accessibility audit: check alt text, title placeholders, reading order. +/// +[ServiceCategory("accessibility")] +[McpTool("accessibility", Title = "Accessibility Audit", Destructive = false, Category = "accessibility")] +public interface IAccessibilityCommands +{ + /// + /// Audit the entire presentation for accessibility issues: missing alt text, missing title placeholders, empty placeholders. + /// + [ServiceAction("audit")] + AccessibilityAuditResult Audit(IPptBatch batch); + + /// + /// Get the reading order (tab order) of shapes on a slide, listed by ZOrderPosition. + /// + /// Batch context + /// 1-based slide index + [ServiceAction("get-reading-order")] + ReadingOrderResult GetReadingOrder(IPptBatch batch, int slideIndex); + + /// + /// Set the reading order of shapes on a slide by reordering their ZOrderPosition. + /// + /// Batch context + /// 1-based slide index + /// Comma-separated shape names in desired reading order + [ServiceAction("set-reading-order")] + OperationResult SetReadingOrder(IPptBatch batch, int slideIndex, string shapeNames); +} diff --git a/src/PptMcp.Core/Commands/Animation/AnimationCommands.cs b/src/PptMcp.Core/Commands/Animation/AnimationCommands.cs new file mode 100644 index 00000000..3a1670b3 --- /dev/null +++ b/src/PptMcp.Core/Commands/Animation/AnimationCommands.cs @@ -0,0 +1,237 @@ +using PptMcp.ComInterop; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Animation; + +public class AnimationCommands : IAnimationCommands +{ + public AnimationListResult List(IPptBatch batch, int slideIndex) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic? timeline = null; + dynamic? mainSequence = null; + try + { + timeline = slide.TimeLine; + mainSequence = timeline.MainSequence; + int count = (int)mainSequence.Count; + + var result = new AnimationListResult + { + Success = true, + FilePath = ctx.PresentationPath, + SlideIndex = slideIndex + }; + + for (int i = 1; i <= count; i++) + { + dynamic effect = mainSequence.Item(i); + try + { + dynamic effectShape = effect.Shape; + int effectType = Convert.ToInt32(effect.EffectType); + int triggerType = Convert.ToInt32(effect.Timing.TriggerType); + float duration = 0; + float delay = 0; + try { duration = (float)effect.Timing.Duration; } catch { } + try { delay = (float)effect.Timing.TriggerDelayTime; } catch { } + + result.Animations.Add(new AnimationInfo + { + Index = i, + ShapeId = (int)effectShape.Id, + ShapeName = effectShape.Name?.ToString() ?? "", + EffectType = GetEffectTypeName(effectType), + Timing = GetTriggerTypeName(triggerType), + Duration = duration, + Delay = delay + }); + ComUtilities.Release(ref effectShape!); + } + finally + { + ComUtilities.Release(ref effect!); + } + } + + return result; + } + finally + { + if (mainSequence != null) ComUtilities.Release(ref mainSequence!); + if (timeline != null) ComUtilities.Release(ref timeline!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult Add(IPptBatch batch, int slideIndex, string shapeName, int effectType, int triggerType) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + dynamic? timeline = null; + dynamic? mainSequence = null; + dynamic? effect = null; + try + { + timeline = slide.TimeLine; + mainSequence = timeline.MainSequence; + // AddEffect(Shape, effectId, level=0, trigger, index=-1) + effect = mainSequence.AddEffect(shape, effectType, 0, triggerType, -1); + + return new OperationResult + { + Success = true, + Action = "add", + Message = $"Added animation effect {GetEffectTypeName(effectType)} to shape '{shapeName}' on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (effect != null) ComUtilities.Release(ref effect!); + if (mainSequence != null) ComUtilities.Release(ref mainSequence!); + if (timeline != null) ComUtilities.Release(ref timeline!); + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult Remove(IPptBatch batch, int slideIndex, int effectIndex) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic? timeline = null; + dynamic? mainSequence = null; + dynamic? effect = null; + try + { + timeline = slide.TimeLine; + mainSequence = timeline.MainSequence; + effect = mainSequence.Item(effectIndex); + effect.Delete(); + + return new OperationResult + { + Success = true, + Action = "remove", + Message = $"Removed animation effect {effectIndex} from slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (effect != null) ComUtilities.Release(ref effect!); + if (mainSequence != null) ComUtilities.Release(ref mainSequence!); + if (timeline != null) ComUtilities.Release(ref timeline!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult Clear(IPptBatch batch, int slideIndex) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic? timeline = null; + dynamic? mainSequence = null; + try + { + timeline = slide.TimeLine; + mainSequence = timeline.MainSequence; + int count = (int)mainSequence.Count; + // Delete in reverse order to avoid index shifting + for (int i = count; i >= 1; i--) + { + dynamic effect = mainSequence.Item(i); + effect.Delete(); + ComUtilities.Release(ref effect!); + } + + return new OperationResult + { + Success = true, + Action = "clear", + Message = $"Cleared {count} animation effects from slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (mainSequence != null) ComUtilities.Release(ref mainSequence!); + if (timeline != null) ComUtilities.Release(ref timeline!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult SetTiming(IPptBatch batch, int slideIndex, int effectIndex, float duration, float delay, int triggerType) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic? timeline = null; + dynamic? mainSequence = null; + dynamic? effect = null; + dynamic? timing = null; + try + { + timeline = slide.TimeLine; + mainSequence = timeline.MainSequence; + effect = mainSequence.Item(effectIndex); + timing = effect.Timing; + timing.Duration = duration; + timing.TriggerDelayTime = delay; + timing.TriggerType = triggerType; + + return new OperationResult + { + Success = true, + Action = "set-timing", + Message = $"Set timing on effect {effectIndex} on slide {slideIndex}: duration={duration}s, delay={delay}s, trigger={GetTriggerTypeName(triggerType)}", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (timing != null) ComUtilities.Release(ref timing!); + if (effect != null) ComUtilities.Release(ref effect!); + if (mainSequence != null) ComUtilities.Release(ref mainSequence!); + if (timeline != null) ComUtilities.Release(ref timeline!); + ComUtilities.Release(ref slide!); + } + }); + } + + private static string GetEffectTypeName(int effectType) => effectType switch + { + 1 => "Appear", + 2 => "Fly", + 3 => "Blinds", + 10 => "Fade", + 16 => "Wipe", + 22 => "RandomBars", + 26 => "Dissolve", + 53 => "GrowShrink", + 55 => "Spin", + _ => $"Effect({effectType})" + }; + + private static string GetTriggerTypeName(int triggerType) => triggerType switch + { + 1 => "OnClick", + 2 => "WithPrevious", + 3 => "AfterPrevious", + _ => $"Trigger({triggerType})" + }; +} diff --git a/src/PptMcp.Core/Commands/Animation/IAnimationCommands.cs b/src/PptMcp.Core/Commands/Animation/IAnimationCommands.cs new file mode 100644 index 00000000..6ffb48c1 --- /dev/null +++ b/src/PptMcp.Core/Commands/Animation/IAnimationCommands.cs @@ -0,0 +1,54 @@ +using PptMcp.ComInterop.Session; +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Animation; + +/// +/// Animation effect operations: list, add, remove, reorder effects on slides. +/// +[ServiceCategory("animation")] +[McpTool("animation", Title = "Animation Operations", Destructive = true, Category = "animations")] +public interface IAnimationCommands +{ + /// + /// List all animation effects on a slide. + /// + [ServiceAction("list")] + AnimationListResult List(IPptBatch batch, int slideIndex); + + /// + /// Add an animation effect to a shape. + /// + /// Batch context + /// 1-based slide index + /// Name of the target shape + /// MsoAnimEffect integer (e.g., 1=Appear, 2=Fly, 10=Fade, 16=Wipe) + /// 1=OnClick (default), 2=WithPrevious, 3=AfterPrevious + [ServiceAction("add")] + OperationResult Add(IPptBatch batch, int slideIndex, string shapeName, int effectType, int triggerType); + + /// + /// Remove an animation effect by its 1-based index in the animation sequence. + /// + [ServiceAction("remove")] + OperationResult Remove(IPptBatch batch, int slideIndex, int effectIndex); + + /// + /// Remove all animation effects from a slide. + /// + [ServiceAction("clear")] + OperationResult Clear(IPptBatch batch, int slideIndex); + + /// + /// Set timing properties for an animation effect. + /// + /// Batch context + /// 1-based slide index + /// 1-based index of the effect in the animation sequence + /// Duration in seconds + /// Delay before start in seconds + /// 1=OnClick, 2=WithPrevious, 3=AfterPrevious + [ServiceAction("set-timing")] + OperationResult SetTiming(IPptBatch batch, int slideIndex, int effectIndex, float duration, float delay, int triggerType); +} diff --git a/src/PptMcp.Core/Commands/Background/BackgroundCommands.cs b/src/PptMcp.Core/Commands/Background/BackgroundCommands.cs new file mode 100644 index 00000000..8d959da9 --- /dev/null +++ b/src/PptMcp.Core/Commands/Background/BackgroundCommands.cs @@ -0,0 +1,191 @@ +using PptMcp.ComInterop; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Background; + +public class BackgroundCommands : IBackgroundCommands +{ + public BackgroundResult GetInfo(IPptBatch batch, int slideIndex) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + try + { + bool followMaster = Convert.ToInt32(slide.FollowMasterBackground) != 0; + var result = new BackgroundResult + { + Success = true, + FilePath = ctx.PresentationPath, + SlideIndex = slideIndex, + FollowMasterBackground = followMaster, + }; + + if (!followMaster) + { + try + { + int fillType = Convert.ToInt32(slide.Background.Fill.Type); + result.FillType = GetFillTypeName(fillType); + + if (fillType == 1) // msoFillSolid + { + int rgb = Convert.ToInt32(slide.Background.Fill.ForeColor.RGB); + result.Color = $"#{rgb:X6}"; + } + } + catch { result.FillType = "Unknown"; } + } + else + { + result.FillType = "Master"; + } + + return result; + } + finally + { + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult SetColor(IPptBatch batch, int slideIndex, string colorHex) + { + ArgumentException.ThrowIfNullOrWhiteSpace(colorHex); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + try + { + slide.FollowMasterBackground = 0; // msoFalse + slide.Background.Fill.Solid(); + slide.Background.Fill.ForeColor.RGB = HexToOleColor(colorHex); + + return new OperationResult + { + Success = true, + Action = "set-color", + Message = $"Set background color of slide {slideIndex} to '{colorHex}'", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult Reset(IPptBatch batch, int slideIndex) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + try + { + slide.FollowMasterBackground = -1; // msoTrue + + return new OperationResult + { + Success = true, + Action = "reset", + Message = $"Reset background of slide {slideIndex} to master", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult SetImage(IPptBatch batch, int slideIndex, string imagePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(imagePath); + + return batch.Execute((ctx, ct) => + { + string fullPath = Path.GetFullPath(imagePath); + if (!System.IO.File.Exists(fullPath)) + throw new FileNotFoundException($"Image file not found: '{fullPath}'"); + + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + try + { + slide.FollowMasterBackground = 0; // msoFalse + slide.Background.Fill.UserPicture(fullPath); + + return new OperationResult + { + Success = true, + Action = "set-image", + Message = $"Set background image of slide {slideIndex} to '{Path.GetFileName(fullPath)}'", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult SetGradient(IPptBatch batch, int slideIndex, string color1, string color2, int gradientStyle) + { + ArgumentException.ThrowIfNullOrWhiteSpace(color1); + ArgumentException.ThrowIfNullOrWhiteSpace(color2); + + if (gradientStyle < 1 || gradientStyle > 6) + throw new ArgumentOutOfRangeException(nameof(gradientStyle), "gradientStyle must be 1-6 (1=Horizontal, 2=Vertical, 3=DiagonalUp, 4=DiagonalDown, 5=FromCorner, 6=FromCenter)"); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + try + { + slide.FollowMasterBackground = 0; // msoFalse + slide.Background.Fill.TwoColorGradient(gradientStyle, 1); + slide.Background.Fill.ForeColor.RGB = HexToOleColor(color1); + slide.Background.Fill.BackColor.RGB = HexToOleColor(color2); + + return new OperationResult + { + Success = true, + Action = "set-gradient", + Message = $"Set gradient background on slide {slideIndex} from '{color1}' to '{color2}' (style {gradientStyle})", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref slide!); + } + }); + } + + private static string GetFillTypeName(int msoFillType) => msoFillType switch + { + 1 => "Solid", + 2 => "Patterned", + 3 => "Gradient", + 4 => "Textured", + 5 => "Background", + 6 => "Picture", + _ => $"Unknown({msoFillType})" + }; + + private static int HexToOleColor(string hex) + { + hex = hex.TrimStart('#'); + if (hex.Length == 3) + hex = string.Concat(hex[0], hex[0], hex[1], hex[1], hex[2], hex[2]); + int r = Convert.ToInt32(hex[..2], 16); + int g = Convert.ToInt32(hex[2..4], 16); + int b = Convert.ToInt32(hex[4..6], 16); + return r | (g << 8) | (b << 16); + } +} diff --git a/src/PptMcp.Core/Commands/Background/IBackgroundCommands.cs b/src/PptMcp.Core/Commands/Background/IBackgroundCommands.cs new file mode 100644 index 00000000..3e9b9d6b --- /dev/null +++ b/src/PptMcp.Core/Commands/Background/IBackgroundCommands.cs @@ -0,0 +1,48 @@ +using PptMcp.ComInterop.Session; +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Background; + +/// +/// Slide background: get, set solid color, set image, reset to master. +/// +[ServiceCategory("background")] +[McpTool("background", Title = "Slide Background", Destructive = true, Category = "background")] +public interface IBackgroundCommands +{ + /// Get the current background info for a slide. + /// Batch context + /// 1-based slide index + [ServiceAction("get")] + BackgroundResult GetInfo(IPptBatch batch, int slideIndex); + + /// Set a solid color background for a slide. + /// Batch context + /// 1-based slide index + /// Hex color string (#RRGGBB) + [ServiceAction("set-color")] + OperationResult SetColor(IPptBatch batch, int slideIndex, string colorHex); + + /// Reset a slide background to follow the slide master. + /// Batch context + /// 1-based slide index + [ServiceAction("reset")] + OperationResult Reset(IPptBatch batch, int slideIndex); + + /// Set an image as slide background. + /// Batch context + /// 1-based slide index + /// Path to the image file + [ServiceAction("set-image")] + OperationResult SetImage(IPptBatch batch, int slideIndex, string imagePath); + + /// Set a two-color gradient background for a slide. + /// Batch context + /// 1-based slide index + /// First gradient color as hex (#RRGGBB) + /// Second gradient color as hex (#RRGGBB) + /// 1=Horizontal, 2=Vertical, 3=DiagonalUp, 4=DiagonalDown, 5=FromCorner, 6=FromCenter + [ServiceAction("set-gradient")] + OperationResult SetGradient(IPptBatch batch, int slideIndex, string color1, string color2, int gradientStyle); +} diff --git a/src/PptMcp.Core/Commands/Chart/ChartCommands.cs b/src/PptMcp.Core/Commands/Chart/ChartCommands.cs new file mode 100644 index 00000000..e515775f --- /dev/null +++ b/src/PptMcp.Core/Commands/Chart/ChartCommands.cs @@ -0,0 +1,328 @@ +using PptMcp.ComInterop; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Chart; + +public class ChartCommands : IChartCommands +{ + public OperationResult Create(IPptBatch batch, int slideIndex, int chartType, float left, float top, float width, float height) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic? shape = null; + try + { + // AddChart(Type, Left, Top, Width, Height) + shape = slide.Shapes.AddChart(chartType, left, top, width, height); + string name = shape?.Name?.ToString() ?? ""; + return new OperationResult + { + Success = true, + Action = "create", + Message = $"Created chart '{name}' (type {chartType}) on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (shape != null) ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public ChartInfoResult GetInfo(IPptBatch batch, int slideIndex, string shapeName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + dynamic? chart = null; + try + { + chart = shape.Chart; + string? title = null; + try + { + if ((bool)chart.HasTitle) + title = chart.ChartTitle.Text?.ToString(); + } + catch { /* Title not accessible */ } + + bool hasLegend = false; + try { hasLegend = (bool)chart.HasLegend; } catch { } + + int seriesCount = 0; + try + { + dynamic seriesCol = chart.SeriesCollection(); + seriesCount = (int)seriesCol.Count; + ComUtilities.Release(ref seriesCol!); + } + catch { } + + int chartTypeVal = Convert.ToInt32(chart.ChartType); + + return new ChartInfoResult + { + Success = true, + FilePath = ctx.PresentationPath, + ShapeId = (int)shape.Id, + ShapeName = shape.Name?.ToString() ?? "", + ChartType = chartTypeVal, + ChartTypeName = GetChartTypeName(chartTypeVal), + Title = title, + HasLegend = hasLegend, + SeriesCount = seriesCount + }; + } + finally + { + if (chart != null) ComUtilities.Release(ref chart!); + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult SetTitle(IPptBatch batch, int slideIndex, string shapeName, string title) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + dynamic? chart = null; + try + { + chart = shape.Chart; + chart.HasTitle = true; + chart.ChartTitle.Text = title; + + return new OperationResult + { + Success = true, + Action = "set-title", + Message = $"Set chart title to '{title}' on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (chart != null) ComUtilities.Release(ref chart!); + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult SetType(IPptBatch batch, int slideIndex, string shapeName, int chartType) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + dynamic? chart = null; + try + { + chart = shape.Chart; + chart.ChartType = chartType; + + return new OperationResult + { + Success = true, + Action = "set-type", + Message = $"Changed chart type to {chartType} on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (chart != null) ComUtilities.Release(ref chart!); + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult Delete(IPptBatch batch, int slideIndex, string shapeName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + try + { + shape.Delete(); + return new OperationResult + { + Success = true, + Action = "delete", + Message = $"Deleted chart shape '{shapeName}' from slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult SetData(IPptBatch batch, int slideIndex, string shapeName, List> values) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + ArgumentNullException.ThrowIfNull(values); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + dynamic? chart = null; + dynamic? chartData = null; + dynamic? workbook = null; + dynamic? dataSheet = null; + try + { + chart = shape.Chart; + chartData = chart.ChartData; + chartData.Activate(); + workbook = chartData.Workbook; + dataSheet = workbook.Worksheets(1); + + int rowCount = values.Count; + int colCount = 0; + for (int r = 0; r < rowCount; r++) + { + int rowLen = values[r].Count; + if (rowLen > colCount) colCount = rowLen; + } + + for (int r = 0; r < rowCount; r++) + { + var row = values[r]; + for (int c = 0; c < colCount; c++) + { + object? cellValue = c < row.Count ? row[c] : null; + // Convert JsonElement to primitive if needed + if (cellValue is System.Text.Json.JsonElement jsonElement) + { + cellValue = jsonElement.ValueKind switch + { + System.Text.Json.JsonValueKind.String => jsonElement.GetString(), + System.Text.Json.JsonValueKind.Number => jsonElement.TryGetInt64(out var i64) ? (object)i64 : jsonElement.GetDouble(), + System.Text.Json.JsonValueKind.True => true, + System.Text.Json.JsonValueKind.False => false, + System.Text.Json.JsonValueKind.Null => null, + _ => jsonElement.ToString() + }; + } + + // Excel COM cells are 1-based + dynamic? cell = null; + try + { + cell = dataSheet.Cells(r + 1, c + 1); + cell.Value2 = cellValue ?? string.Empty; + } + finally + { + if (cell != null) ComUtilities.Release(ref cell!); + } + } + } + + try { workbook.Close(false); } catch { /* best-effort close */ } + + return new OperationResult + { + Success = true, + Action = "set-data", + Message = $"Set chart data ({rowCount} rows × {colCount} columns) on '{shapeName}' slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (dataSheet != null) ComUtilities.Release(ref dataSheet!); + if (workbook != null) ComUtilities.Release(ref workbook!); + if (chartData != null) ComUtilities.Release(ref chartData!); + if (chart != null) ComUtilities.Release(ref chart!); + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + private static string GetChartTypeName(int chartType) => chartType switch + { + 1 => "xlArea", + 4 => "xlLine", + 5 => "xlPie", + 51 => "xlColumnClustered", + 52 => "xlColumnStacked", + 54 => "xlBarClustered", + 65 => "xlBarStacked", + 72 => "xlDoughnut", + -4169 => "xl3DColumn", + -4120 => "xlXYScatter", + _ => $"Unknown({chartType})" + }; + + public OperationResult SetLegend(IPptBatch batch, int slideIndex, string shapeName, bool visible, int position) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + dynamic? chart = null; + try + { + chart = shape.Chart; + chart.HasLegend = visible; + if (visible) + { + chart.Legend.Position = position; + } + + string posName = position switch + { + -4107 => "Bottom", + -4131 => "Left", + -4152 => "Right", + -4160 => "Top", + -4161 => "TopRight", + _ => $"Position({position})" + }; + + return new OperationResult + { + Success = true, + Action = "set-legend", + Message = visible + ? $"Set chart legend to '{posName}' on '{shapeName}' slide {slideIndex}" + : $"Hidden chart legend on '{shapeName}' slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (chart != null) ComUtilities.Release(ref chart!); + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } +} diff --git a/src/PptMcp.Core/Commands/Chart/IChartCommands.cs b/src/PptMcp.Core/Commands/Chart/IChartCommands.cs new file mode 100644 index 00000000..9c83d236 --- /dev/null +++ b/src/PptMcp.Core/Commands/Chart/IChartCommands.cs @@ -0,0 +1,76 @@ +using PptMcp.ComInterop.Session; +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Chart; + +/// +/// Embedded chart operations: create, get info, set title, set type, delete. +/// +[ServiceCategory("chart")] +[McpTool("chart", Title = "Chart Operations", Destructive = true, Category = "charts")] +public interface IChartCommands +{ + /// + /// Create an embedded chart on a slide. + /// + /// Batch context + /// 1-based slide index + /// XlChartType integer (e.g., 4=xlLine, 5=xlPie, 51=xlColumnClustered, -4169=xl3DColumn) + /// Position from left in points + /// Position from top in points + /// Width in points + /// Height in points + [ServiceAction("create")] + OperationResult Create(IPptBatch batch, int slideIndex, int chartType, float left, float top, float width, float height); + + /// + /// Get information about a chart shape. + /// + [ServiceAction("get-info")] + ChartInfoResult GetInfo(IPptBatch batch, int slideIndex, string shapeName); + + /// + /// Set the chart title. + /// + [ServiceAction("set-title")] + OperationResult SetTitle(IPptBatch batch, int slideIndex, string shapeName, string title); + + /// + /// Change the chart type. + /// + /// Batch context + /// 1-based slide index + /// Name of the chart shape + /// XlChartType integer + [ServiceAction("set-type")] + OperationResult SetType(IPptBatch batch, int slideIndex, string shapeName, int chartType); + + /// + /// Delete a chart shape. + /// + [ServiceAction("delete")] + OperationResult Delete(IPptBatch batch, int slideIndex, string shapeName); + + /// + /// Set chart data from a 2D array of values. + /// Opens the embedded data worksheet, writes values, then closes. + /// + /// Batch context + /// 1-based slide index + /// Name of the chart shape + /// 2D array of values (rows × columns) + [ServiceAction("set-data")] + OperationResult SetData(IPptBatch batch, int slideIndex, string shapeName, List> values); + + /// + /// Set chart legend visibility and position. + /// + /// Batch context + /// 1-based slide index + /// Name of the chart shape + /// Whether the legend is visible + /// Legend position: -4107=Bottom, -4131=Left, -4152=Right, -4160=Top, -4161=TopRight + [ServiceAction("set-legend")] + OperationResult SetLegend(IPptBatch batch, int slideIndex, string shapeName, bool visible, int position); +} diff --git a/src/PptMcp.Core/Commands/Comment/CommentCommands.cs b/src/PptMcp.Core/Commands/Comment/CommentCommands.cs new file mode 100644 index 00000000..9b6937fd --- /dev/null +++ b/src/PptMcp.Core/Commands/Comment/CommentCommands.cs @@ -0,0 +1,215 @@ +using PptMcp.ComInterop; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Comment; + +public class CommentCommands : ICommentCommands +{ + public CommentListResult List(IPptBatch batch, int slideIndex) + { + return batch.Execute((ctx, ct) => + { + var result = new CommentListResult { Success = true, FilePath = ctx.PresentationPath }; + dynamic pres = ctx.Presentation; + + if (slideIndex > 0) + { + dynamic slide = pres.Slides.Item(slideIndex); + try + { + ReadCommentsFromSlide(slide, slideIndex, result); + } + finally + { + ComUtilities.Release(ref slide!); + } + } + else + { + dynamic slides = pres.Slides; + try + { + int count = (int)slides.Count; + for (int i = 1; i <= count; i++) + { + dynamic slide = slides.Item(i); + try + { + ReadCommentsFromSlide(slide, i, result); + } + finally + { + ComUtilities.Release(ref slide!); + } + } + } + finally + { + ComUtilities.Release(ref slides!); + } + } + + return result; + }); + } + + public OperationResult Add(IPptBatch batch, int slideIndex, string text, string author, float left, float top) + { + ArgumentException.ThrowIfNullOrWhiteSpace(text); + ArgumentException.ThrowIfNullOrWhiteSpace(author); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + try + { + // Comments.Add2(Left, Top, Author, AuthorInitials, Text) + string initials = author.Length >= 2 + ? string.Concat(author.AsSpan(0, 1).ToString().ToUpperInvariant(), author.AsSpan(1, 1)) + : author.ToUpperInvariant(); + slide.Comments.Add2(left, top, author, initials, text); + + return new OperationResult + { + Success = true, + Action = "add", + Message = $"Added comment by '{author}' on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult Delete(IPptBatch batch, int slideIndex, int commentIndex) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic? comments = null; + dynamic? comment = null; + try + { + comments = slide.Comments; + comment = comments.Item(commentIndex); + comment.Delete(); + + return new OperationResult + { + Success = true, + Action = "delete", + Message = $"Deleted comment {commentIndex} on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (comment != null) ComUtilities.Release(ref comment!); + if (comments != null) ComUtilities.Release(ref comments!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult Clear(IPptBatch batch, int slideIndex) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = ctx.Presentation; + int cleared = 0; + + void ClearSlide(dynamic s) + { + dynamic comments = s.Comments; + try + { + // Delete from last to first to avoid index shift + for (int i = (int)comments.Count; i >= 1; i--) + { + dynamic c = comments.Item(i); + try { c.Delete(); cleared++; } + finally { ComUtilities.Release(ref c!); } + } + } + finally + { + ComUtilities.Release(ref comments!); + } + } + + if (slideIndex > 0) + { + dynamic slide = pres.Slides.Item(slideIndex); + try { ClearSlide(slide); } + finally { ComUtilities.Release(ref slide!); } + } + else + { + dynamic slides = pres.Slides; + try + { + int count = (int)slides.Count; + for (int i = 1; i <= count; i++) + { + dynamic slide = slides.Item(i); + try { ClearSlide(slide); } + finally { ComUtilities.Release(ref slide!); } + } + } + finally + { + ComUtilities.Release(ref slides!); + } + } + + return new OperationResult + { + Success = true, + Action = "clear", + Message = slideIndex > 0 + ? $"Cleared {cleared} comment(s) from slide {slideIndex}" + : $"Cleared {cleared} comment(s) from all slides", + FilePath = ctx.PresentationPath + }; + }); + } + + private static void ReadCommentsFromSlide(dynamic slide, int slideIdx, CommentListResult result) + { + dynamic comments = slide.Comments; + try + { + int count = (int)comments.Count; + for (int i = 1; i <= count; i++) + { + dynamic c = comments.Item(i); + try + { + var info = new CommentInfo + { + SlideIndex = slideIdx, + CommentIndex = i, + Text = c.Text?.ToString() ?? "", + Author = c.Author?.ToString() ?? "", + Left = Convert.ToSingle(c.Left), + Top = Convert.ToSingle(c.Top), + }; + try { info.DateTime = c.DateTime?.ToString(); } catch { } + result.Comments.Add(info); + } + finally + { + ComUtilities.Release(ref c!); + } + } + } + finally + { + ComUtilities.Release(ref comments!); + } + } +} diff --git a/src/PptMcp.Core/Commands/Comment/ICommentCommands.cs b/src/PptMcp.Core/Commands/Comment/ICommentCommands.cs new file mode 100644 index 00000000..73006870 --- /dev/null +++ b/src/PptMcp.Core/Commands/Comment/ICommentCommands.cs @@ -0,0 +1,42 @@ +using PptMcp.ComInterop.Session; +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Comment; + +/// +/// Slide comments: list, add, delete. +/// +[ServiceCategory("comment")] +[McpTool("comment", Title = "Slide Comments", Destructive = true, Category = "comments")] +public interface ICommentCommands +{ + /// List all comments on a slide (0 = all slides). + /// Batch context + /// 1-based slide index, or 0 for all slides + [ServiceAction("list")] + CommentListResult List(IPptBatch batch, int slideIndex); + + /// Add a comment to a slide. + /// Batch context + /// 1-based slide index + /// Comment text + /// Author name + /// Horizontal position in points (0 = top-left) + /// Vertical position in points (0 = top-left) + [ServiceAction("add")] + OperationResult Add(IPptBatch batch, int slideIndex, string text, string author, float left, float top); + + /// Delete a comment by index on a slide. + /// Batch context + /// 1-based slide index + /// 1-based comment index + [ServiceAction("delete")] + OperationResult Delete(IPptBatch batch, int slideIndex, int commentIndex); + + /// Delete all comments on a slide (0 = all slides). + /// Batch context + /// 1-based slide index, or 0 for all slides + [ServiceAction("clear")] + OperationResult Clear(IPptBatch batch, int slideIndex); +} diff --git a/src/PptMcp.Core/Commands/CustomShow/CustomShowCommands.cs b/src/PptMcp.Core/Commands/CustomShow/CustomShowCommands.cs new file mode 100644 index 00000000..88fcb467 --- /dev/null +++ b/src/PptMcp.Core/Commands/CustomShow/CustomShowCommands.cs @@ -0,0 +1,134 @@ +using PptMcp.ComInterop; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.CustomShow; + +public class CustomShowCommands : ICustomShowCommands +{ + public CustomShowListResult List(IPptBatch batch) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = ctx.Presentation; + dynamic shows = pres.SlideShowSettings.NamedSlideShows; + try + { + var result = new CustomShowListResult { Success = true, FilePath = ctx.PresentationPath }; + int count = (int)shows.Count; + for (int i = 1; i <= count; i++) + { + dynamic show = shows.Item(i); + try + { + var info = new CustomShowInfo + { + Index = i, + Name = show.Name?.ToString() ?? "", + SlideCount = (int)show.Count + }; + // Get slide IDs + for (int s = 1; s <= info.SlideCount; s++) + { + try { info.SlideIds.Add((int)show.SlideIDs(s)); } catch { } + } + result.Shows.Add(info); + } + finally + { + ComUtilities.Release(ref show!); + } + } + return result; + } + finally + { + ComUtilities.Release(ref shows!); + } + }); + } + + public OperationResult Create(IPptBatch batch, string showName, string slideIndices) + { + ArgumentException.ThrowIfNullOrWhiteSpace(showName); + ArgumentException.ThrowIfNullOrWhiteSpace(slideIndices); + + return batch.Execute((ctx, ct) => + { + dynamic pres = ctx.Presentation; + dynamic shows = pres.SlideShowSettings.NamedSlideShows; + try + { + int[] indices = slideIndices.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(s => int.Parse(s, System.Globalization.CultureInfo.InvariantCulture)) + .ToArray(); + + // Build array of slide IDs from indices + dynamic slides = pres.Slides; + try + { + int[] slideIds = new int[indices.Length]; + for (int i = 0; i < indices.Length; i++) + { + dynamic slide = slides.Item(indices[i]); + slideIds[i] = (int)slide.SlideID; + ComUtilities.Release(ref slide!); + } + + shows.Add(showName, slideIds); + } + finally + { + ComUtilities.Release(ref slides!); + } + + return new OperationResult + { + Success = true, + Action = "create", + Message = $"Created custom show '{showName}' with {indices.Length} slide(s)", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref shows!); + } + }); + } + + public OperationResult Delete(IPptBatch batch, string showName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(showName); + + return batch.Execute((ctx, ct) => + { + dynamic pres = ctx.Presentation; + dynamic shows = pres.SlideShowSettings.NamedSlideShows; + try + { + dynamic show = shows.Item(showName); + try + { + show.Delete(); + } + finally + { + ComUtilities.Release(ref show!); + } + + return new OperationResult + { + Success = true, + Action = "delete", + Message = $"Deleted custom show '{showName}'", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref shows!); + } + }); + } +} diff --git a/src/PptMcp.Core/Commands/CustomShow/ICustomShowCommands.cs b/src/PptMcp.Core/Commands/CustomShow/ICustomShowCommands.cs new file mode 100644 index 00000000..b582ef44 --- /dev/null +++ b/src/PptMcp.Core/Commands/CustomShow/ICustomShowCommands.cs @@ -0,0 +1,28 @@ +using PptMcp.ComInterop.Session; +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.CustomShow; + +/// +/// Custom slide show management: list, create, delete, run. +/// +[ServiceCategory("customshow")] +[McpTool("customshow", Title = "Custom Shows", Destructive = true, Category = "customshow")] +public interface ICustomShowCommands +{ + /// List all custom shows in the presentation. + [ServiceAction("list")] + CustomShowListResult List(IPptBatch batch); + + /// Create a custom show from specified slide indices. + /// Batch context + /// Name for the custom show + /// Comma-separated 1-based slide indices (e.g. "1,3,5") + [ServiceAction("create")] + OperationResult Create(IPptBatch batch, string showName, string slideIndices); + + /// Delete a custom show by name. + [ServiceAction("delete")] + OperationResult Delete(IPptBatch batch, string showName); +} diff --git a/src/PptMcp.Core/Commands/Design/DesignCommands.cs b/src/PptMcp.Core/Commands/Design/DesignCommands.cs new file mode 100644 index 00000000..4c13f59d --- /dev/null +++ b/src/PptMcp.Core/Commands/Design/DesignCommands.cs @@ -0,0 +1,180 @@ +using PptMcp.ComInterop; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Design; + +public class DesignCommands : IDesignCommands +{ + public DesignListResult List(IPptBatch batch) + { + return batch.Execute((ctx, ct) => + { + dynamic designs = ((dynamic)ctx.Presentation).Designs; + try + { + int count = (int)designs.Count; + + var result = new DesignListResult + { + Success = true, + FilePath = ctx.PresentationPath + }; + + for (int i = 1; i <= count; i++) + { + dynamic design = designs.Item(i); + try + { + int layoutCount = 0; + try + { + layoutCount = (int)design.SlideMaster.CustomLayouts.Count; + } + catch { } + + result.Designs.Add(new DesignInfo + { + Index = i, + Name = design.Name?.ToString() ?? "", + LayoutCount = layoutCount + }); + } + finally + { + ComUtilities.Release(ref design!); + } + } + + return result; + } + finally + { + ComUtilities.Release(ref designs!); + } + }); + } + + public OperationResult ApplyTheme(IPptBatch batch, string themePath) + { + return batch.Execute((ctx, ct) => + { + if (!System.IO.File.Exists(themePath)) + throw new System.IO.FileNotFoundException($"Theme file not found: {themePath}"); + + ((dynamic)ctx.Presentation).ApplyTheme(themePath); + + return new OperationResult + { + Success = true, + Action = "apply-theme", + Message = $"Applied theme from '{System.IO.Path.GetFileName(themePath)}'", + FilePath = ctx.PresentationPath + }; + }); + } + + public ThemeColorResult GetColors(IPptBatch batch, int designIndex) + { + return batch.Execute((ctx, ct) => + { + dynamic designs = ((dynamic)ctx.Presentation).Designs; + int idx = designIndex <= 0 ? 1 : designIndex; + dynamic design = designs.Item(idx); + dynamic? slideMaster = null; + dynamic? theme = null; + dynamic? colorScheme = null; + try + { + slideMaster = design.SlideMaster; + theme = slideMaster.Theme; + colorScheme = theme.ThemeColorScheme; + + var colors = new Dictionary(); + // MsoThemeColorSchemeIndex: 1-12 + string[] colorNames = [ + "Dark1", "Light1", "Dark2", "Light2", + "Accent1", "Accent2", "Accent3", "Accent4", + "Accent5", "Accent6", "Hyperlink", "FollowedHyperlink" + ]; + + for (int i = 1; i <= Math.Min(12, colorNames.Length); i++) + { + try + { + dynamic colorItem = colorScheme.Colors(i); + int rgb = (int)colorItem.RGB; + // COM returns BGR, convert to #RRGGBB + int r = rgb & 0xFF; + int g = (rgb >> 8) & 0xFF; + int b = (rgb >> 16) & 0xFF; + colors[colorNames[i - 1]] = $"#{r:X2}{g:X2}{b:X2}"; + ComUtilities.Release(ref colorItem!); + } + catch { } + } + + return new ThemeColorResult + { + Success = true, + FilePath = ctx.PresentationPath, + DesignName = design.Name?.ToString() ?? "", + Colors = colors + }; + } + finally + { + if (colorScheme != null) ComUtilities.Release(ref colorScheme!); + if (theme != null) ComUtilities.Release(ref theme!); + if (slideMaster != null) ComUtilities.Release(ref slideMaster!); + ComUtilities.Release(ref design!); + ComUtilities.Release(ref designs!); + } + }); + } + + public ColorSchemeListResult ListColorSchemes(IPptBatch batch) + { + return batch.Execute((ctx, ct) => + { + dynamic colorSchemes = ((dynamic)ctx.Presentation).ColorSchemes; + try + { + var result = new ColorSchemeListResult { Success = true, FilePath = ctx.PresentationPath }; + int count = (int)colorSchemes.Count; + for (int i = 1; i <= count; i++) + { + dynamic cs = colorSchemes.Item(i); + try + { + var info = new ColorSchemeInfo { Index = i }; + // RGBColor indices: 1-8 map to standard PowerPoint color roles + string[] roleNames = ["Background", "Text", "Shadow", "Title", "Fill", "Accent1", "Accent2", "Accent3"]; + for (int c = 1; c <= Math.Min(8, roleNames.Length); c++) + { + try + { + int rgb = (int)cs.Colors(c).RGB; + int r = rgb & 0xFF; + int g = (rgb >> 8) & 0xFF; + int b = (rgb >> 16) & 0xFF; + info.Colors[roleNames[c - 1]] = $"#{r:X2}{g:X2}{b:X2}"; + } + catch { } + } + result.ColorSchemes.Add(info); + } + finally + { + ComUtilities.Release(ref cs!); + } + } + return result; + } + finally + { + ComUtilities.Release(ref colorSchemes!); + } + }); + } +} diff --git a/src/PptMcp.Core/Commands/Design/IDesignCommands.cs b/src/PptMcp.Core/Commands/Design/IDesignCommands.cs new file mode 100644 index 00000000..efe9010d --- /dev/null +++ b/src/PptMcp.Core/Commands/Design/IDesignCommands.cs @@ -0,0 +1,41 @@ +using PptMcp.ComInterop.Session; +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Design; + +/// +/// Theme and design operations: list designs, apply themes, get theme colors. +/// +[ServiceCategory("design")] +[McpTool("design", Title = "Design Operations", Destructive = true, Category = "design")] +public interface IDesignCommands +{ + /// + /// List all designs (themes) in the presentation. + /// + [ServiceAction("list")] + DesignListResult List(IPptBatch batch); + + /// + /// Apply an Office theme file (.thmx) to the presentation. + /// + /// Batch context + /// Full path to .thmx theme file + [ServiceAction("apply-theme")] + OperationResult ApplyTheme(IPptBatch batch, string themePath); + + /// + /// Get the theme color palette for a design. + /// + /// Batch context + /// 1-based design index (0 = first design) + [ServiceAction("get-colors")] + ThemeColorResult GetColors(IPptBatch batch, int designIndex); + + /// + /// List all color schemes in the presentation. + /// + [ServiceAction("list-color-schemes")] + ColorSchemeListResult ListColorSchemes(IPptBatch batch); +} diff --git a/src/PptMcp.Core/Commands/DocumentProperty/DocumentPropertyCommands.cs b/src/PptMcp.Core/Commands/DocumentProperty/DocumentPropertyCommands.cs new file mode 100644 index 00000000..b3dceb08 --- /dev/null +++ b/src/PptMcp.Core/Commands/DocumentProperty/DocumentPropertyCommands.cs @@ -0,0 +1,182 @@ +using PptMcp.ComInterop; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.DocumentProperty; + +public class DocumentPropertyCommands : IDocumentPropertyCommands +{ + // Built-in property indices for BuiltinDocumentProperties + private const int PropTitle = 1; + private const int PropSubject = 2; + private const int PropAuthor = 3; + private const int PropKeywords = 4; + private const int PropComments = 6; + private const int PropCompany = 15; + private const int PropCategory = 18; + + public DocumentPropertyResult GetAll(IPptBatch batch) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = (dynamic)ctx.Presentation; + dynamic? builtIn = null; + try + { + builtIn = pres.BuiltInDocumentProperties; + return new DocumentPropertyResult + { + Success = true, + FilePath = ctx.PresentationPath, + Title = GetProp(builtIn, PropTitle), + Subject = GetProp(builtIn, PropSubject), + Author = GetProp(builtIn, PropAuthor), + Keywords = GetProp(builtIn, PropKeywords), + Comments = GetProp(builtIn, PropComments), + Company = GetProp(builtIn, PropCompany), + Category = GetProp(builtIn, PropCategory) + }; + } + finally + { + if (builtIn != null) ComUtilities.Release(ref builtIn!); + } + }); + } + + public OperationResult SetAll(IPptBatch batch, string title, string subject, string author, string keywords, string comments, string company, string category) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = (dynamic)ctx.Presentation; + dynamic? builtIn = null; + try + { + builtIn = pres.BuiltInDocumentProperties; + if (!string.IsNullOrEmpty(title)) SetProp(builtIn, PropTitle, title); + if (!string.IsNullOrEmpty(subject)) SetProp(builtIn, PropSubject, subject); + if (!string.IsNullOrEmpty(author)) SetProp(builtIn, PropAuthor, author); + if (!string.IsNullOrEmpty(keywords)) SetProp(builtIn, PropKeywords, keywords); + if (!string.IsNullOrEmpty(comments)) SetProp(builtIn, PropComments, comments); + if (!string.IsNullOrEmpty(company)) SetProp(builtIn, PropCompany, company); + if (!string.IsNullOrEmpty(category)) SetProp(builtIn, PropCategory, category); + + return new OperationResult + { + Success = true, + Action = "set", + Message = "Updated document properties", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (builtIn != null) ComUtilities.Release(ref builtIn!); + } + }); + } + + private static string GetProp(dynamic props, int index) + { + dynamic? prop = null; + try + { + prop = props.Item(index); + return prop.Value?.ToString() ?? ""; + } + catch + { + return ""; + } + finally + { + if (prop != null) ComUtilities.Release(ref prop!); + } + } + + private static void SetProp(dynamic props, int index, string value) + { + dynamic? prop = null; + try + { + prop = props.Item(index); + prop.Value = value; + } + catch { /* Some props may be read-only */ } + finally + { + if (prop != null) ComUtilities.Release(ref prop!); + } + } + + public OperationResult GetCustom(IPptBatch batch, string propertyName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(propertyName); + + return batch.Execute((ctx, ct) => + { + dynamic pres = ctx.Presentation; + dynamic customProps = pres.CustomDocumentProperties; + try + { + dynamic prop = customProps.Item(propertyName); + string value = prop.Value?.ToString() ?? ""; + ComUtilities.Release(ref prop!); + + return new OperationResult + { + Success = true, + Action = "get-custom", + Message = $"{propertyName} = {value}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref customProps!); + } + }); + } + + public OperationResult SetCustom(IPptBatch batch, string propertyName, string propertyValue) + { + ArgumentException.ThrowIfNullOrWhiteSpace(propertyName); + + return batch.Execute((ctx, ct) => + { + dynamic pres = ctx.Presentation; + dynamic customProps = pres.CustomDocumentProperties; + try + { + // Try to update existing property first + bool exists = false; + try + { + dynamic existing = customProps.Item(propertyName); + existing.Value = propertyValue; + ComUtilities.Release(ref existing!); + exists = true; + } + catch { /* Property doesn't exist yet */ } + + if (!exists) + { + // Add new custom property (Type 4 = msoPropertyTypeString) + customProps.Add(propertyName, false, 4, propertyValue); + } + + return new OperationResult + { + Success = true, + Action = "set-custom", + Message = $"Set custom property '{propertyName}' = '{propertyValue}'", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref customProps!); + } + }); + } +} diff --git a/src/PptMcp.Core/Commands/DocumentProperty/IDocumentPropertyCommands.cs b/src/PptMcp.Core/Commands/DocumentProperty/IDocumentPropertyCommands.cs new file mode 100644 index 00000000..9aeae57c --- /dev/null +++ b/src/PptMcp.Core/Commands/DocumentProperty/IDocumentPropertyCommands.cs @@ -0,0 +1,50 @@ +using PptMcp.ComInterop.Session; +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.DocumentProperty; + +/// +/// Document property management: read and write presentation metadata like title, author, subject, keywords. +/// +[ServiceCategory("docproperty")] +[McpTool("docproperty", Title = "Document Property Operations", Destructive = false, Category = "metadata")] +public interface IDocumentPropertyCommands +{ + /// + /// Get all built-in document properties (title, author, subject, keywords, comments, company, category). + /// + [ServiceAction("get")] + DocumentPropertyResult GetAll(IPptBatch batch); + + /// + /// Set built-in document properties. Pass null or empty to leave a property unchanged. + /// + /// Batch context + /// Presentation title + /// Subject or topic + /// Author name + /// Keywords for search (comma-separated) + /// Description or comments + /// Company or organization name + /// Category + [ServiceAction("set")] + OperationResult SetAll(IPptBatch batch, string title, string subject, string author, string keywords, string comments, string company, string category); + + /// + /// Get a custom document property by name. + /// + /// Batch context + /// Custom property name + [ServiceAction("get-custom")] + OperationResult GetCustom(IPptBatch batch, string propertyName); + + /// + /// Set a custom document property (creates if not exists). + /// + /// Batch context + /// Custom property name + /// Property value (string) + [ServiceAction("set-custom")] + OperationResult SetCustom(IPptBatch batch, string propertyName, string propertyValue); +} diff --git a/src/PptMcp.Core/Commands/Export/ExportCommands.cs b/src/PptMcp.Core/Commands/Export/ExportCommands.cs new file mode 100644 index 00000000..9e2a285a --- /dev/null +++ b/src/PptMcp.Core/Commands/Export/ExportCommands.cs @@ -0,0 +1,371 @@ +using PptMcp.ComInterop; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Export; + +public class ExportCommands : IExportCommands +{ + public ExportResult ToPdf(IPptBatch batch, string destinationPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath); + + return batch.Execute((ctx, ct) => + { + string fullPath = Path.GetFullPath(destinationPath); + string? directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + Directory.CreateDirectory(directory); + + dynamic pres = ctx.Presentation; + // ppSaveAsPDF = 32 + pres.SaveAs(fullPath, 32); + + return new ExportResult + { + Success = true, + FilePath = ctx.PresentationPath, + OutputPath = fullPath, + Format = "PDF" + }; + }); + } + + public ExportResult SlideToImage(IPptBatch batch, int slideIndex, string destinationPath, int width, int height) + { + ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath); + + return batch.Execute((ctx, ct) => + { + string fullPath = Path.GetFullPath(destinationPath); + string? directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + Directory.CreateDirectory(directory); + + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + try + { + slide.Export(fullPath, "PNG", width > 0 ? width : 1920, height > 0 ? height : 1080); + return new ExportResult + { + Success = true, + FilePath = ctx.PresentationPath, + OutputPath = fullPath, + Format = "PNG" + }; + } + finally + { + ComUtilities.Release(ref slide!); + } + }); + } + + public ExportResult ToVideo(IPptBatch batch, string destinationPath, int defaultSlideSeconds, int resolution) + { + ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath); + + return batch.Execute((ctx, ct) => + { + string fullPath = Path.GetFullPath(destinationPath); + string? directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + Directory.CreateDirectory(directory); + + dynamic pres = ctx.Presentation; + int seconds = defaultSlideSeconds > 0 ? defaultSlideSeconds : 5; + // Resolution: 1=1080p, 2=720p, 3=480p (maps to ppResolution enum) + int res = resolution >= 1 && resolution <= 3 ? resolution : 1; + + // CreateVideo(FileName, UseTimingsAndNarrations, DefaultSlideDuration, VertResolution, FramesPerSecond, Quality) + pres.CreateVideo(fullPath, false, seconds, res == 1 ? 1080 : res == 2 ? 720 : 480, 30, 85); + + // Wait for video creation to complete + int timeout = 300; // 5 minutes max + while (Convert.ToInt32(pres.CreateVideoStatus) == 1 && timeout > 0) // ppMediaTaskStatusInProgress = 1 + { + System.Threading.Thread.Sleep(1000); + timeout--; + } + + int status = Convert.ToInt32(pres.CreateVideoStatus); + if (status == 3) // ppMediaTaskStatusFailed + throw new InvalidOperationException("Video creation failed."); + + return new ExportResult + { + Success = true, + FilePath = ctx.PresentationPath, + OutputPath = fullPath, + Format = "MP4" + }; + }); + } + + public OperationResult Print(IPptBatch batch, int copies, int fromSlide, int toSlide) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = ctx.Presentation; + int numCopies = copies > 0 ? copies : 1; + int from = fromSlide > 0 ? fromSlide : -1; + int to = toSlide > 0 ? toSlide : -1; + + if (from > 0 && to > 0) + pres.PrintOut(from, to, "", numCopies); + else + pres.PrintOut(1, (int)pres.Slides.Count, "", numCopies); + + return new OperationResult + { + Success = true, + Action = "print", + Message = $"Printed {numCopies} copy(ies)" + + (from > 0 ? $" (slides {from}-{to})" : " (all slides)"), + FilePath = ctx.PresentationPath + }; + }); + } + + public ExportResult SaveAs(IPptBatch batch, string destinationPath, int format) + { + ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath); + + return batch.Execute((ctx, ct) => + { + string fullPath = Path.GetFullPath(destinationPath); + string? directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + Directory.CreateDirectory(directory); + + // ppSaveAsDefault=11, ppSaveAsOpenXMLPresentation=24, ppSaveAsOpenXMLPresentationMacroEnabled=25, + // ppSaveAsTemplate=5, ppSaveAsOpenXMLShow=28, ppSaveAsPDF=32, ppSaveAsXPS=33, ppSaveAsODP=37 + int ppFormat = format switch + { + 1 => 24, // pptx + 2 => 25, // pptm + 3 => 5, // potx (template) + 4 => 28, // ppsx (show) + 5 => 32, // pdf + 6 => 33, // xps + 7 => 37, // odp + _ => 24 // default to pptx + }; + + string formatName = format switch + { + 1 => "PPTX", + 2 => "PPTM", + 3 => "POTX", + 4 => "PPSX", + 5 => "PDF", + 6 => "XPS", + 7 => "ODP", + _ => "PPTX" + }; + + dynamic pres = ctx.Presentation; + pres.SaveAs(fullPath, ppFormat); + + return new ExportResult + { + Success = true, + FilePath = ctx.PresentationPath, + OutputPath = fullPath, + Format = formatName + }; + }); + } + + public ExportResult AllSlidesToImages(IPptBatch batch, string destinationDirectory, int width, int height) + { + ArgumentException.ThrowIfNullOrWhiteSpace(destinationDirectory); + + return batch.Execute((ctx, ct) => + { + string fullDir = Path.GetFullPath(destinationDirectory); + if (!Directory.Exists(fullDir)) + Directory.CreateDirectory(fullDir); + + dynamic slides = ((dynamic)ctx.Presentation).Slides; + try + { + int count = (int)slides.Count; + int w = width > 0 ? width : 1920; + int h = height > 0 ? height : 1080; + + for (int i = 1; i <= count; i++) + { + dynamic slide = slides.Item(i); + try + { + string fileName = $"slide_{i:D3}.png"; + string filePath = Path.Combine(fullDir, fileName); + slide.Export(filePath, "PNG", w, h); + } + finally + { + ComUtilities.Release(ref slide!); + } + } + } + finally + { + ComUtilities.Release(ref slides!); + } + + return new ExportResult + { + Success = true, + FilePath = ctx.PresentationPath, + OutputPath = fullDir, + Format = "PNG" + }; + }); + } + + public OperationResult ExtractText(IPptBatch batch, string destinationPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath); + + return batch.Execute((ctx, ct) => + { + string fullPath = Path.GetFullPath(destinationPath); + string? directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + Directory.CreateDirectory(directory); + + dynamic slides = ((dynamic)ctx.Presentation).Slides; + try + { + int slideCount = (int)slides.Count; + using var writer = new StreamWriter(fullPath, false, System.Text.Encoding.UTF8); + + for (int i = 1; i <= slideCount; i++) + { + dynamic slide = slides.Item(i); + dynamic shapes = slide.Shapes; + try + { + writer.WriteLine($"=== Slide {i} ==="); + int shapeCount = (int)shapes.Count; + + for (int j = 1; j <= shapeCount; j++) + { + dynamic shape = shapes.Item(j); + try + { + if ((bool)shape.HasTextFrame) + { + dynamic textFrame = shape.TextFrame; + dynamic textRange = textFrame.TextRange; + try + { + string text = textRange.Text?.ToString() ?? ""; + if (!string.IsNullOrWhiteSpace(text)) + writer.WriteLine(text); + } + finally + { + ComUtilities.Release(ref textRange!); + ComUtilities.Release(ref textFrame!); + } + } + } + finally + { + ComUtilities.Release(ref shape!); + } + } + + writer.WriteLine(); + } + finally + { + ComUtilities.Release(ref shapes!); + ComUtilities.Release(ref slide!); + } + } + } + finally + { + ComUtilities.Release(ref slides!); + } + + return new OperationResult + { + Success = true, + Action = "extract-text", + Message = $"Extracted text to '{fullPath}'", + FilePath = ctx.PresentationPath + }; + }); + } + + public OperationResult ExtractImages(IPptBatch batch, string destinationDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(destinationDirectory); + + return batch.Execute((ctx, ct) => + { + string fullDir = Path.GetFullPath(destinationDirectory); + if (!Directory.Exists(fullDir)) + Directory.CreateDirectory(fullDir); + + dynamic slides = ((dynamic)ctx.Presentation).Slides; + try + { + int slideCount = (int)slides.Count; + int imageCount = 0; + + for (int i = 1; i <= slideCount; i++) + { + dynamic slide = slides.Item(i); + dynamic shapes = slide.Shapes; + try + { + int shapeCount = (int)shapes.Count; + for (int j = 1; j <= shapeCount; j++) + { + dynamic shape = shapes.Item(j); + try + { + int shapeType = Convert.ToInt32(shape.Type); + // msoPicture=13, msoLinkedPicture=11 + if (shapeType == 13 || shapeType == 11) + { + imageCount++; + string fileName = $"slide{i:D3}_image{imageCount:D3}.png"; + string filePath = Path.Combine(fullDir, fileName); + // ppShapeFormatPNG = 2 + shape.Export(filePath, 2); + } + } + finally + { + ComUtilities.Release(ref shape!); + } + } + } + finally + { + ComUtilities.Release(ref shapes!); + ComUtilities.Release(ref slide!); + } + } + } + finally + { + ComUtilities.Release(ref slides!); + } + + return new OperationResult + { + Success = true, + Action = "extract-images", + Message = $"Extracted images to '{fullDir}'", + FilePath = ctx.PresentationPath + }; + }); + } +} diff --git a/src/PptMcp.Core/Commands/Export/IExportCommands.cs b/src/PptMcp.Core/Commands/Export/IExportCommands.cs new file mode 100644 index 00000000..40e2a527 --- /dev/null +++ b/src/PptMcp.Core/Commands/Export/IExportCommands.cs @@ -0,0 +1,88 @@ +using PptMcp.ComInterop.Session; +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Export; + +/// +/// Export presentations to PDF, images, or other formats. +/// +[ServiceCategory("export")] +[McpTool("export", Title = "Export Operations", Destructive = false, Category = "export")] +public interface IExportCommands +{ + /// Export the presentation to PDF. + /// Batch context + /// Output PDF file path + [ServiceAction("to-pdf")] + ExportResult ToPdf(IPptBatch batch, string destinationPath); + + /// Export a single slide as an image (PNG). + /// Batch context + /// 1-based slide index + /// Output image file path + /// Image width in pixels (default: 1920) + /// Image height in pixels (default: 1080) + [ServiceAction("slide-to-image")] + ExportResult SlideToImage(IPptBatch batch, int slideIndex, string destinationPath, int width, int height); + + /// + /// Export the presentation as a video (MP4). + /// Resolution: 1=FullHD(1080p), 2=HD(720p), 3=Standard(480p). + /// + /// Batch context + /// Output video file path (.mp4) + /// Seconds per slide (default: 5) + /// 1=1080p, 2=720p, 3=480p + [ServiceAction("to-video")] + ExportResult ToVideo(IPptBatch batch, string destinationPath, int defaultSlideSeconds, int resolution); + + /// + /// Print the presentation. + /// + /// Batch context + /// Number of copies (default: 1) + /// First slide to print (0 = from beginning) + /// Last slide to print (0 = to end) + [ServiceAction("print")] + OperationResult Print(IPptBatch batch, int copies, int fromSlide, int toSlide); + + /// + /// Save the presentation as a different format. + /// Format: 1=pptx, 2=pptm (macro-enabled), 3=potx (template), + /// 4=ppsx (show), 5=pdf, 6=xps, 7=odp (OpenDocument). + /// + /// Batch context + /// Output file path + /// Format code (1-7) + [ServiceAction("save-as")] + ExportResult SaveAs(IPptBatch batch, string destinationPath, int format); + + /// + /// Export all slides as individual PNG images (slide_001.png, slide_002.png, etc.). + /// + /// Batch context + /// Directory to save images + /// Image width in pixels (default: 1920) + /// Image height in pixels (default: 1080) + [ServiceAction("all-slides-to-images")] + ExportResult AllSlidesToImages(IPptBatch batch, string destinationDirectory, int width, int height); + + /// + /// Extract all text from the presentation to a text file. + /// Iterates all slides and shapes, writing text with slide headers. + /// + /// Batch context + /// Output text file path + [ServiceAction("extract-text")] + OperationResult ExtractText(IPptBatch batch, string destinationPath); + + /// + /// Extract all images (pictures) from the presentation as PNG files. + /// Exports shapes of type Picture (13) or LinkedPicture (11). + /// + /// Batch context + /// Directory to save extracted images + [ServiceAction("extract-images")] + OperationResult ExtractImages(IPptBatch batch, string destinationDirectory); +} diff --git a/src/PptMcp.Core/Commands/File/FileCommands.cs b/src/PptMcp.Core/Commands/File/FileCommands.cs new file mode 100644 index 00000000..2543a348 --- /dev/null +++ b/src/PptMcp.Core/Commands/File/FileCommands.cs @@ -0,0 +1,36 @@ +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.File; + +public class FileCommands : IFileCommands +{ + public FileValidationInfo Test(string filePath) + { + string fullPath = Path.GetFullPath(filePath); + + if (!System.IO.File.Exists(fullPath)) + { + return new FileValidationInfo + { + Success = false, + Exists = false, + FilePath = fullPath + }; + } + + var fileInfo = new FileInfo(fullPath); + string ext = fileInfo.Extension.ToLowerInvariant(); + + return new FileValidationInfo + { + Success = true, + Exists = true, + FilePath = fullPath, + FileName = fileInfo.Name, + FileSizeBytes = fileInfo.Length, + IsReadOnly = fileInfo.IsReadOnly, + IsMacroEnabled = ext == ".pptm", + SlideCount = -1 // Requires opening the file; set by caller if needed + }; + } +} diff --git a/src/PptMcp.Core/Commands/File/IFileCommands.cs b/src/PptMcp.Core/Commands/File/IFileCommands.cs new file mode 100644 index 00000000..2ae20c97 --- /dev/null +++ b/src/PptMcp.Core/Commands/File/IFileCommands.cs @@ -0,0 +1,20 @@ +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.File; + +/// +/// File management commands for PowerPoint presentations. +/// Handles file validation and metadata retrieval. +/// +[ServiceCategory("file")] +[NoSession] +public interface IFileCommands +{ + /// + /// Validate a PowerPoint file and return metadata (size, slide count, macro status). + /// + /// Path to the .pptx or .pptm file + [ServiceAction("test")] + FileValidationInfo Test(string filePath); +} diff --git a/src/PptMcp.Core/Commands/HeaderFooter/HeaderFooterCommands.cs b/src/PptMcp.Core/Commands/HeaderFooter/HeaderFooterCommands.cs new file mode 100644 index 00000000..317620fc --- /dev/null +++ b/src/PptMcp.Core/Commands/HeaderFooter/HeaderFooterCommands.cs @@ -0,0 +1,106 @@ +using PptMcp.ComInterop; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.HeaderFooter; + +public class HeaderFooterCommands : IHeaderFooterCommands +{ + public HeaderFooterResult GetInfo(IPptBatch batch) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = ctx.Presentation; + // Slide-level headers/footers are in Designs(1).SlideMaster.HeadersFooters + // Simpler: use first slide's HeadersFooters as representative + dynamic slides = pres.Slides; + try + { + if ((int)slides.Count == 0) + { + return new HeaderFooterResult + { + Success = true, + FilePath = ctx.PresentationPath, + }; + } + + dynamic slide = slides.Item(1); + dynamic? hf = null; + try + { + hf = slide.HeadersFooters; + var result = new HeaderFooterResult + { + Success = true, + FilePath = ctx.PresentationPath, + }; + + try { result.ShowFooter = Convert.ToInt32(hf.Footer.Visible) != 0; } catch { } + try { result.FooterText = hf.Footer.Text?.ToString(); } catch { } + try { result.ShowSlideNumber = Convert.ToInt32(hf.SlideNumber.Visible) != 0; } catch { } + try { result.ShowDate = Convert.ToInt32(hf.DateAndTime.Visible) != 0; } catch { } + + return result; + } + finally + { + if (hf != null) ComUtilities.Release(ref hf!); + ComUtilities.Release(ref slide!); + } + } + finally + { + ComUtilities.Release(ref slides!); + } + }); + } + + public OperationResult Update(IPptBatch batch, string? footerText, bool? showFooter, bool? showSlideNumber, bool? showDate) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = ctx.Presentation; + dynamic slides = pres.Slides; + try + { + int count = (int)slides.Count; + for (int i = 1; i <= count; i++) + { + dynamic slide = slides.Item(i); + dynamic? hf = null; + try + { + hf = slide.HeadersFooters; + + if (showFooter.HasValue) + hf.Footer.Visible = showFooter.Value ? -1 : 0; + if (footerText != null) + hf.Footer.Text = footerText; + if (showSlideNumber.HasValue) + hf.SlideNumber.Visible = showSlideNumber.Value ? -1 : 0; + if (showDate.HasValue) + hf.DateAndTime.Visible = showDate.Value ? -1 : 0; + } + finally + { + if (hf != null) ComUtilities.Release(ref hf!); + ComUtilities.Release(ref slide!); + } + } + + return new OperationResult + { + Success = true, + Action = "set", + Message = $"Updated header/footer settings on {count} slide(s)", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref slides!); + } + }); + } +} diff --git a/src/PptMcp.Core/Commands/HeaderFooter/IHeaderFooterCommands.cs b/src/PptMcp.Core/Commands/HeaderFooter/IHeaderFooterCommands.cs new file mode 100644 index 00000000..53c9ba86 --- /dev/null +++ b/src/PptMcp.Core/Commands/HeaderFooter/IHeaderFooterCommands.cs @@ -0,0 +1,29 @@ +using PptMcp.ComInterop.Session; +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.HeaderFooter; + +/// +/// Presentation headers and footers: get settings, set date/page number/footer text. +/// +[ServiceCategory("headerfooter")] +[McpTool("headerfooter", Title = "Headers & Footers", Destructive = true, Category = "headerfooter")] +public interface IHeaderFooterCommands +{ + /// Get header/footer settings for the presentation. + /// Batch context + [ServiceAction("get")] + HeaderFooterResult GetInfo(IPptBatch batch); + + /// + /// Set header/footer options. Pass null to leave unchanged. + /// + /// Batch context + /// Footer text (null = don't change) + /// Show footer on slides + /// Show slide numbers + /// Show date/time + [ServiceAction("set")] + OperationResult Update(IPptBatch batch, string? footerText, bool? showFooter, bool? showSlideNumber, bool? showDate); +} diff --git a/src/PptMcp.Core/Commands/Hyperlink/HyperlinkCommands.cs b/src/PptMcp.Core/Commands/Hyperlink/HyperlinkCommands.cs new file mode 100644 index 00000000..f6f7e1cb --- /dev/null +++ b/src/PptMcp.Core/Commands/Hyperlink/HyperlinkCommands.cs @@ -0,0 +1,339 @@ +using PptMcp.ComInterop; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Hyperlink; + +public class HyperlinkCommands : IHyperlinkCommands +{ + public OperationResult Add(IPptBatch batch, int slideIndex, string shapeName, string address, string? subAddress = null, string? screenTip = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + ArgumentException.ThrowIfNullOrWhiteSpace(address); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + dynamic? actionSettings = null; + dynamic? actionSetting = null; + dynamic? hyperlink = null; + try + { + actionSettings = shape.ActionSettings; + // ppMouseClick = 1 + actionSetting = actionSettings.Item(1); + // ppActionHyperlink = 7 + actionSetting.Action = 7; + hyperlink = actionSetting.Hyperlink; + hyperlink.Address = address; + hyperlink.SubAddress = subAddress; + if (!string.IsNullOrEmpty(screenTip)) + hyperlink.ScreenTip = screenTip; + + return new OperationResult + { + Success = true, + Action = "add", + Message = $"Added hyperlink '{address}' to shape '{shapeName}' on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (hyperlink != null) ComUtilities.Release(ref hyperlink!); + if (actionSetting != null) ComUtilities.Release(ref actionSetting!); + if (actionSettings != null) ComUtilities.Release(ref actionSettings!); + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public HyperlinkResult Read(IPptBatch batch, int slideIndex, string shapeName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + dynamic? actionSettings = null; + dynamic? actionSetting = null; + dynamic? hyperlink = null; + try + { + actionSettings = shape.ActionSettings; + actionSetting = actionSettings.Item(1); // ppMouseClick = 1 + int action = Convert.ToInt32(actionSetting.Action); + + string address = ""; + string subAddress = ""; + string screenTip = ""; + bool hasHyperlink = action == 7; // ppActionHyperlink + + if (hasHyperlink) + { + hyperlink = actionSetting.Hyperlink; + address = hyperlink.Address?.ToString() ?? ""; + subAddress = hyperlink.SubAddress?.ToString() ?? ""; + try { screenTip = hyperlink.ScreenTip?.ToString() ?? ""; } catch { } + } + + return new HyperlinkResult + { + Success = true, + FilePath = ctx.PresentationPath, + SlideIndex = slideIndex, + ShapeName = shapeName, + HasHyperlink = hasHyperlink, + Address = address, + SubAddress = subAddress, + ScreenTip = screenTip + }; + } + finally + { + if (hyperlink != null) ComUtilities.Release(ref hyperlink!); + if (actionSetting != null) ComUtilities.Release(ref actionSetting!); + if (actionSettings != null) ComUtilities.Release(ref actionSettings!); + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult Remove(IPptBatch batch, int slideIndex, string shapeName) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + dynamic? actionSettings = null; + dynamic? actionSetting = null; + try + { + actionSettings = shape.ActionSettings; + actionSetting = actionSettings.Item(1); // ppMouseClick = 1 + // ppActionNone = 0 + actionSetting.Action = 0; + + return new OperationResult + { + Success = true, + Action = "remove", + Message = $"Removed hyperlink from shape '{shapeName}' on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (actionSetting != null) ComUtilities.Release(ref actionSetting!); + if (actionSettings != null) ComUtilities.Release(ref actionSettings!); + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public HyperlinkListResult List(IPptBatch batch) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = (dynamic)ctx.Presentation; + dynamic slides = pres.Slides; + try + { + int slideCount = Convert.ToInt32(slides.Count); + + var result = new HyperlinkListResult + { + Success = true, + FilePath = ctx.PresentationPath + }; + + int globalIndex = 1; + for (int si = 1; si <= slideCount; si++) + { + dynamic? slide = null; + dynamic? shapes = null; + try + { + slide = slides.Item(si); + shapes = slide.Shapes; + int shapeCount = Convert.ToInt32(shapes.Count); + + for (int shi = 1; shi <= shapeCount; shi++) + { + dynamic? shape = null; + dynamic? actionSettings = null; + dynamic? actionSetting = null; + dynamic? hyperlink = null; + try + { + shape = shapes.Item(shi); + actionSettings = shape.ActionSettings; + actionSetting = actionSettings.Item(1); // ppMouseClick = 1 + int action = Convert.ToInt32(actionSetting.Action); + + if (action == 7) // ppActionHyperlink + { + hyperlink = actionSetting.Hyperlink; + string address = hyperlink.Address?.ToString() ?? ""; + string subAddress = hyperlink.SubAddress?.ToString() ?? ""; + string screenTip = ""; + try { screenTip = hyperlink.ScreenTip?.ToString() ?? ""; } catch { } + string shapeName = shape.Name?.ToString() ?? ""; + + result.Hyperlinks.Add(new HyperlinkInfo + { + Index = globalIndex++, + Address = address, + SubAddress = subAddress, + ScreenTip = screenTip, + SlideIndex = si, + ShapeName = shapeName + }); + } + } + finally + { + if (hyperlink != null) ComUtilities.Release(ref hyperlink!); + if (actionSetting != null) ComUtilities.Release(ref actionSetting!); + if (actionSettings != null) ComUtilities.Release(ref actionSettings!); + if (shape != null) ComUtilities.Release(ref shape!); + } + } + } + finally + { + if (shapes != null) ComUtilities.Release(ref shapes!); + if (slide != null) ComUtilities.Release(ref slide!); + } + } + + return result; + } + finally + { + ComUtilities.Release(ref slides!); + } + }); + } + + public OperationResult Validate(IPptBatch batch) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = (dynamic)ctx.Presentation; + dynamic slides = pres.Slides; + try + { + int slideCount = Convert.ToInt32(slides.Count); + var lines = new List(); + int totalLinks = 0; + int brokenCount = 0; + + for (int si = 1; si <= slideCount; si++) + { + dynamic? slide = null; + dynamic? shapes = null; + try + { + slide = slides.Item(si); + shapes = slide.Shapes; + int shapeCount = Convert.ToInt32(shapes.Count); + + for (int shi = 1; shi <= shapeCount; shi++) + { + dynamic? shape = null; + dynamic? actionSettings = null; + dynamic? actionSetting = null; + dynamic? hyperlink = null; + try + { + shape = shapes.Item(shi); + actionSettings = shape.ActionSettings; + actionSetting = actionSettings.Item(1); // ppMouseClick = 1 + int action = Convert.ToInt32(actionSetting.Action); + + if (action == 7) // ppActionHyperlink + { + hyperlink = actionSetting.Hyperlink; + string address = hyperlink.Address?.ToString() ?? ""; + string subAddress = hyperlink.SubAddress?.ToString() ?? ""; + string shapeName = shape.Name?.ToString() ?? ""; + totalLinks++; + + string status; + if (string.IsNullOrWhiteSpace(address) && string.IsNullOrWhiteSpace(subAddress)) + { + status = "empty"; + brokenCount++; + } + else if (!string.IsNullOrWhiteSpace(subAddress) && string.IsNullOrWhiteSpace(address)) + { + // Internal slide link (subAddress only, e.g. slide number) + status = "internal"; + } + else if (address.StartsWith('#')) + { + status = "internal"; + } + else if (address.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + status = "external"; + } + else + { + // Treat as file path + if (System.IO.File.Exists(address)) + { + status = "valid"; + } + else + { + status = "broken"; + brokenCount++; + } + } + + string target = !string.IsNullOrWhiteSpace(address) ? address : subAddress; + lines.Add($"Slide {si}, Shape '{shapeName}': {target} [{status}]"); + } + } + finally + { + if (hyperlink != null) ComUtilities.Release(ref hyperlink!); + if (actionSetting != null) ComUtilities.Release(ref actionSetting!); + if (actionSettings != null) ComUtilities.Release(ref actionSettings!); + if (shape != null) ComUtilities.Release(ref shape!); + } + } + } + finally + { + if (shapes != null) ComUtilities.Release(ref shapes!); + if (slide != null) ComUtilities.Release(ref slide!); + } + } + + string summary = $"Validated {totalLinks} hyperlink(s): {brokenCount} broken/empty."; + if (lines.Count > 0) + summary += "\n" + string.Join("\n", lines); + + return new OperationResult + { + Success = true, + Action = "validate", + Message = summary, + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref slides!); + } + }); + } +} diff --git a/src/PptMcp.Core/Commands/Hyperlink/IHyperlinkCommands.cs b/src/PptMcp.Core/Commands/Hyperlink/IHyperlinkCommands.cs new file mode 100644 index 00000000..aff9d61c --- /dev/null +++ b/src/PptMcp.Core/Commands/Hyperlink/IHyperlinkCommands.cs @@ -0,0 +1,51 @@ +using PptMcp.ComInterop.Session; +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Hyperlink; + +/// +/// Hyperlink management: add, remove, and get hyperlinks on shapes and text. +/// +[ServiceCategory("hyperlink")] +[McpTool("hyperlink", Title = "Hyperlink Operations", Destructive = true, Category = "content")] +public interface IHyperlinkCommands +{ + /// + /// Add a hyperlink to a shape (click on shape navigates to URL or slide). + /// + /// Batch context + /// 1-based slide index + /// Name of the shape to add hyperlink to + /// URL (https://...) or empty for slide link + /// Slide number for internal links (e.g. '3' to jump to slide 3), or empty + /// Optional tooltip text shown on hover + [ServiceAction("add")] + OperationResult Add(IPptBatch batch, int slideIndex, string shapeName, string address, string? subAddress = null, string? screenTip = null); + + /// + /// Get the hyperlink on a shape. + /// + [ServiceAction("get")] + HyperlinkResult Read(IPptBatch batch, int slideIndex, string shapeName); + + /// + /// Remove the hyperlink from a shape. + /// + [ServiceAction("remove")] + OperationResult Remove(IPptBatch batch, int slideIndex, string shapeName); + + /// + /// List all hyperlinks in the presentation. + /// + [ServiceAction("list")] + HyperlinkListResult List(IPptBatch batch); + + /// + /// Validate all hyperlinks in the presentation and report broken or empty ones. + /// Checks every slide and shape for hyperlinks, classifying each as valid, broken, empty, external, or internal. + /// + /// Batch context + [ServiceAction("validate")] + OperationResult Validate(IPptBatch batch); +} diff --git a/src/PptMcp.Core/Commands/Image/IImageCommands.cs b/src/PptMcp.Core/Commands/Image/IImageCommands.cs new file mode 100644 index 00000000..6e0f7c4d --- /dev/null +++ b/src/PptMcp.Core/Commands/Image/IImageCommands.cs @@ -0,0 +1,35 @@ +using PptMcp.ComInterop.Session; +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Image; + +/// +/// Image operations: insert pictures into slides. +/// +[ServiceCategory("image")] +[McpTool("image", Title = "Image Operations", Destructive = true, Category = "media")] +public interface IImageCommands +{ + /// Insert a picture from a file path onto a slide. + /// Batch context + /// 1-based slide index + /// Path to the image file + /// Position from left in points + /// Position from top in points + /// Width in points (0 = original) + /// Height in points (0 = original) + [ServiceAction("insert")] + OperationResult Insert(IPptBatch batch, int slideIndex, string imagePath, float left, float top, float width, float height); + + /// Crop an image shape by specifying crop amounts on each side. + /// Batch context + /// 1-based slide index + /// Name of the picture shape + /// Crop from left in points (0 = no crop) + /// Crop from right in points (0 = no crop) + /// Crop from top in points (0 = no crop) + /// Crop from bottom in points (0 = no crop) + [ServiceAction("crop")] + OperationResult Crop(IPptBatch batch, int slideIndex, string shapeName, float cropLeft, float cropRight, float cropTop, float cropBottom); +} diff --git a/src/PptMcp.Core/Commands/Image/ImageCommands.cs b/src/PptMcp.Core/Commands/Image/ImageCommands.cs new file mode 100644 index 00000000..45cb4be5 --- /dev/null +++ b/src/PptMcp.Core/Commands/Image/ImageCommands.cs @@ -0,0 +1,82 @@ +using PptMcp.ComInterop; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Image; + +public class ImageCommands : IImageCommands +{ + public OperationResult Insert(IPptBatch batch, int slideIndex, string imagePath, float left, float top, float width, float height) + { + return batch.Execute((ctx, ct) => + { + string fullImagePath = Path.GetFullPath(imagePath); + if (!System.IO.File.Exists(fullImagePath)) + throw new FileNotFoundException($"Image file not found: '{fullImagePath}'"); + + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + try + { + // AddPicture(FileName, LinkToFile, SaveWithDocument, Left, Top, Width, Height) + // msoFalse=0, msoTrue=-1 + dynamic shape = (width > 0 && height > 0) + ? slide.Shapes.AddPicture(fullImagePath, 0, -1, left, top, width, height) + : slide.Shapes.AddPicture(fullImagePath, 0, -1, left, top); + + string name = shape.Name?.ToString() ?? ""; + ComUtilities.Release(ref shape!); + + return new OperationResult + { + Success = true, + Action = "insert", + Message = $"Inserted image '{Path.GetFileName(fullImagePath)}' as '{name}' on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult Crop(IPptBatch batch, int slideIndex, string shapeName, float cropLeft, float cropRight, float cropTop, float cropBottom) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + try + { + dynamic picFormat = shape.PictureFormat; + try + { + picFormat.CropLeft = cropLeft; + picFormat.CropRight = cropRight; + picFormat.CropTop = cropTop; + picFormat.CropBottom = cropBottom; + } + finally + { + ComUtilities.Release(ref picFormat!); + } + + return new OperationResult + { + Success = true, + Action = "crop", + Message = $"Cropped image '{shapeName}' on slide {slideIndex} (L:{cropLeft}, R:{cropRight}, T:{cropTop}, B:{cropBottom})", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } +} diff --git a/src/PptMcp.Core/Commands/Master/IMasterCommands.cs b/src/PptMcp.Core/Commands/Master/IMasterCommands.cs new file mode 100644 index 00000000..f986bdc4 --- /dev/null +++ b/src/PptMcp.Core/Commands/Master/IMasterCommands.cs @@ -0,0 +1,31 @@ +using PptMcp.ComInterop.Session; +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Master; + +/// +/// Slide master and layout operations: list masters, list layouts, get placeholders. +/// +[ServiceCategory("master")] +[McpTool("master", Title = "Master & Layout Operations", Destructive = false, Category = "design")] +public interface IMasterCommands +{ + /// List all slide masters and their custom layouts. + [ServiceAction("list")] + MasterListResult List(IPptBatch batch); + + /// List all shapes on a specific slide master. + /// Batch context + /// 1-based slide master index + [ServiceAction("list-shapes")] + OperationResult ListShapes(IPptBatch batch, int masterIndex); + + /// Edit the text content of a shape on a slide master. + /// Batch context + /// 1-based slide master index + /// Name of the shape to edit + /// New text content + [ServiceAction("edit-shape-text")] + OperationResult EditShapeText(IPptBatch batch, int masterIndex, string shapeName, string text); +} diff --git a/src/PptMcp.Core/Commands/Master/MasterCommands.cs b/src/PptMcp.Core/Commands/Master/MasterCommands.cs new file mode 100644 index 00000000..6b2a83e9 --- /dev/null +++ b/src/PptMcp.Core/Commands/Master/MasterCommands.cs @@ -0,0 +1,153 @@ +using PptMcp.ComInterop; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Commands.Slide; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Master; + +public class MasterCommands : IMasterCommands +{ + public MasterListResult List(IPptBatch batch) + { + return batch.Execute((ctx, ct) => + { + var result = new MasterListResult { Success = true, FilePath = ctx.PresentationPath }; + dynamic pres = ctx.Presentation; + dynamic masters = pres.SlideMasters; + try + { + int masterCount = (int)masters.Count; + + for (int m = 1; m <= masterCount; m++) + { + dynamic master = masters.Item(m); + try + { + var masterInfo = new MasterInfo + { + Name = master.Name?.ToString() ?? $"Master {m}" + }; + + dynamic layouts = master.CustomLayouts; + try + { + int layoutCount = (int)layouts.Count; + for (int l = 1; l <= layoutCount; l++) + { + dynamic layout = layouts.Item(l); + try + { + masterInfo.Layouts.Add(new LayoutInfo + { + Name = layout.Name?.ToString() ?? $"Layout {l}", + Index = l + }); + } + finally + { + ComUtilities.Release(ref layout!); + } + } + } + finally + { + ComUtilities.Release(ref layouts!); + } + + result.Masters.Add(masterInfo); + } + finally + { + ComUtilities.Release(ref master!); + } + } + + return result; + } + finally + { + ComUtilities.Release(ref masters!); + } + }); + } + + public OperationResult ListShapes(IPptBatch batch, int masterIndex) + { + return batch.Execute((ctx, ct) => + { + dynamic masters = ((dynamic)ctx.Presentation).SlideMasters; + dynamic master = masters.Item(masterIndex); + dynamic shapes = master.Shapes; + try + { + int count = (int)shapes.Count; + var lines = new List(count); + + for (int i = 1; i <= count; i++) + { + dynamic shape = shapes.Item(i); + try + { + string name = shape.Name?.ToString() ?? $"Shape {i}"; + int shapeType = Convert.ToInt32(shape.Type); + string typeName = ShapeHelpers.GetShapeTypeName(shapeType); + lines.Add($"{name} ({typeName})"); + } + finally + { + ComUtilities.Release(ref shape!); + } + } + + return new OperationResult + { + Success = true, + Action = "list-shapes", + Message = count > 0 + ? $"Master {masterIndex} has {count} shape(s):\n" + string.Join("\n", lines) + : $"Master {masterIndex} has no shapes.", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref shapes!); + ComUtilities.Release(ref master!); + ComUtilities.Release(ref masters!); + } + }); + } + + public OperationResult EditShapeText(IPptBatch batch, int masterIndex, string shapeName, string text) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic masters = ((dynamic)ctx.Presentation).SlideMasters; + dynamic master = masters.Item(masterIndex); + dynamic shape = master.Shapes.Item(shapeName); + try + { + if (Convert.ToInt32(shape.HasTextFrame) == 0) + throw new InvalidOperationException($"Shape '{shapeName}' on master {masterIndex} does not have a text frame."); + + shape.TextFrame.TextRange.Text = text; + + return new OperationResult + { + Success = true, + Action = "edit-shape-text", + Message = $"Set text on shape '{shapeName}' (master {masterIndex})", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref master!); + ComUtilities.Release(ref masters!); + } + }); + } +} diff --git a/src/PptMcp.Core/Commands/Media/IMediaCommands.cs b/src/PptMcp.Core/Commands/Media/IMediaCommands.cs new file mode 100644 index 00000000..ee697c70 --- /dev/null +++ b/src/PptMcp.Core/Commands/Media/IMediaCommands.cs @@ -0,0 +1,47 @@ +using PptMcp.ComInterop.Session; +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Media; + +/// +/// Media management: insert audio and video files into slides. +/// Supports linking or embedding media files. +/// +[ServiceCategory("media")] +[McpTool("media", Title = "Media Operations", Destructive = true, Category = "content")] +public interface IMediaCommands +{ + /// + /// Insert an audio file onto a slide. Supports .mp3, .wav, .m4a, .wma. + /// + /// Batch context + /// 1-based slide index + /// Full path to the audio file + /// Position from left in points + /// Position from top in points + /// If true, link to file instead of embedding (smaller file size) + /// If true, save media with document when linking + [ServiceAction("insert-audio")] + OperationResult InsertAudio(IPptBatch batch, int slideIndex, string filePath, float left, float top, bool linkToFile, bool saveWithDocument); + + /// + /// Insert a video file onto a slide. Supports .mp4, .avi, .mov, .wmv. + /// + /// Batch context + /// 1-based slide index + /// Full path to the video file + /// Position from left in points + /// Position from top in points + /// Width in points (0 = use video native width) + /// Height in points (0 = use video native height) + /// If true, link to file instead of embedding + [ServiceAction("insert-video")] + OperationResult InsertVideo(IPptBatch batch, int slideIndex, string filePath, float left, float top, float width, float height, bool linkToFile); + + /// + /// Get information about a media shape (audio or video) on a slide. + /// + [ServiceAction("get-info")] + MediaInfoResult GetInfo(IPptBatch batch, int slideIndex, string shapeName); +} diff --git a/src/PptMcp.Core/Commands/Media/MediaCommands.cs b/src/PptMcp.Core/Commands/Media/MediaCommands.cs new file mode 100644 index 00000000..de902f64 --- /dev/null +++ b/src/PptMcp.Core/Commands/Media/MediaCommands.cs @@ -0,0 +1,126 @@ +using PptMcp.ComInterop; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Media; + +public class MediaCommands : IMediaCommands +{ + public OperationResult InsertAudio(IPptBatch batch, int slideIndex, string filePath, float left, float top, bool linkToFile, bool saveWithDocument) + { + return batch.Execute((ctx, ct) => + { + if (!System.IO.File.Exists(filePath)) + throw new FileNotFoundException($"Audio file not found: {filePath}"); + + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic? shape = null; + try + { + // AddMediaObject2(FileName, LinkToFile, SaveWithDocument, Left, Top, Width, Height) + // -1 = msoTrue, 0 = msoFalse + int link = linkToFile ? -1 : 0; + int saveWith = saveWithDocument ? -1 : 0; + shape = slide.Shapes.AddMediaObject2(filePath, link, saveWith, left, top); + string name = shape.Name?.ToString() ?? ""; + + return new OperationResult + { + Success = true, + Action = "insert-audio", + Message = $"Inserted audio '{System.IO.Path.GetFileName(filePath)}' as '{name}' on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (shape != null) ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult InsertVideo(IPptBatch batch, int slideIndex, string filePath, float left, float top, float width, float height, bool linkToFile) + { + return batch.Execute((ctx, ct) => + { + if (!System.IO.File.Exists(filePath)) + throw new FileNotFoundException($"Video file not found: {filePath}"); + + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic? shape = null; + try + { + int link = linkToFile ? -1 : 0; + // saveWithDocument = msoTrue (-1) by default for videos + shape = slide.Shapes.AddMediaObject2(filePath, link, -1, left, top, width > 0 ? width : -1, height > 0 ? height : -1); + string name = shape.Name?.ToString() ?? ""; + + return new OperationResult + { + Success = true, + Action = "insert-video", + Message = $"Inserted video '{System.IO.Path.GetFileName(filePath)}' as '{name}' on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (shape != null) ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public MediaInfoResult GetInfo(IPptBatch batch, int slideIndex, string shapeName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + try + { + // ppMediaTypeMovie = 3, ppMediaTypeSound = 1 + int mediaType = 0; + string mediaTypeName = "Unknown"; + string sourceFile = ""; + + try + { + mediaType = Convert.ToInt32(shape.MediaType); + mediaTypeName = mediaType switch + { + 1 => "Audio", + 2 => "Other", + 3 => "Video", + _ => $"Unknown({mediaType})" + }; + } + catch { } + + try { sourceFile = shape.LinkFormat?.SourceFullName?.ToString() ?? ""; } catch { } + + return new MediaInfoResult + { + Success = true, + FilePath = ctx.PresentationPath, + SlideIndex = slideIndex, + ShapeName = shapeName, + MediaType = mediaTypeName, + SourceFile = sourceFile, + Left = Convert.ToSingle(shape.Left), + Top = Convert.ToSingle(shape.Top), + Width = Convert.ToSingle(shape.Width), + Height = Convert.ToSingle(shape.Height) + }; + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } +} diff --git a/src/PptMcp.Core/Commands/Notes/INotesCommands.cs b/src/PptMcp.Core/Commands/Notes/INotesCommands.cs new file mode 100644 index 00000000..916a35f2 --- /dev/null +++ b/src/PptMcp.Core/Commands/Notes/INotesCommands.cs @@ -0,0 +1,33 @@ +using PptMcp.ComInterop.Session; +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Notes; + +/// +/// Speaker notes: get, set, clear. +/// +[ServiceCategory("notes")] +[McpTool("notes", Title = "Speaker Notes", Destructive = true, Category = "notes")] +public interface INotesCommands +{ + /// Get speaker notes for a slide. + [ServiceAction("get")] + NotesResult GetNotes(IPptBatch batch, int slideIndex); + + /// Set speaker notes for a slide. + [ServiceAction("set")] + OperationResult SetNotes(IPptBatch batch, int slideIndex, string text); + + /// Clear speaker notes for a slide. + [ServiceAction("clear")] + OperationResult Clear(IPptBatch batch, int slideIndex); + + /// Append text to existing speaker notes (adds newline separator). + [ServiceAction("append")] + OperationResult Append(IPptBatch batch, int slideIndex, string text); + + /// Read speaker notes from all slides in the presentation. + [ServiceAction("read-all")] + OperationResult ReadAll(IPptBatch batch); +} diff --git a/src/PptMcp.Core/Commands/Notes/NotesCommands.cs b/src/PptMcp.Core/Commands/Notes/NotesCommands.cs new file mode 100644 index 00000000..b51bb74f --- /dev/null +++ b/src/PptMcp.Core/Commands/Notes/NotesCommands.cs @@ -0,0 +1,140 @@ +using PptMcp.ComInterop; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Notes; + +public class NotesCommands : INotesCommands +{ + public NotesResult GetNotes(IPptBatch batch, int slideIndex) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + try + { + string text = ""; + try + { + // Notes page has placeholders; placeholder 2 is the text body + text = slide.NotesPage.Shapes.Placeholders.Item(2).TextFrame.TextRange.Text?.ToString() ?? ""; + } + catch { } + + return new NotesResult + { + Success = true, + FilePath = ctx.PresentationPath, + SlideIndex = slideIndex, + Text = text + }; + } + finally + { + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult SetNotes(IPptBatch batch, int slideIndex, string text) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + try + { + slide.NotesPage.Shapes.Placeholders.Item(2).TextFrame.TextRange.Text = text; + return new OperationResult + { + Success = true, + Action = "set", + Message = $"Set notes on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult Clear(IPptBatch batch, int slideIndex) + { + return SetNotes(batch, slideIndex, ""); + } + + public OperationResult Append(IPptBatch batch, int slideIndex, string text) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + try + { + string existing = ""; + try { existing = slide.NotesPage.Shapes.Placeholders.Item(2).TextFrame.TextRange.Text?.ToString() ?? ""; } catch { } + + string newText = string.IsNullOrEmpty(existing) ? text : existing + "\n" + text; + slide.NotesPage.Shapes.Placeholders.Item(2).TextFrame.TextRange.Text = newText; + + return new OperationResult + { + Success = true, + Action = "append", + Message = $"Appended notes on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult ReadAll(IPptBatch batch) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = ctx.Presentation; + dynamic slides = pres.Slides; + try + { + int count = (int)slides.Count; + var lines = new List(); + + for (int i = 1; i <= count; i++) + { + dynamic slide = slides.Item(i); + try + { + string text = ""; + try + { + text = slide.NotesPage.Shapes.Placeholders.Item(2).TextFrame.TextRange.Text?.ToString() ?? ""; + } + catch { } + + lines.Add($"Slide {i}: {text}"); + } + finally + { + ComUtilities.Release(ref slide!); + } + } + + return new OperationResult + { + Success = true, + Action = "read-all", + Message = string.Join("\n", lines), + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref slides!); + } + }); + } +} diff --git a/src/PptMcp.Core/Commands/PageSetup/IPageSetupCommands.cs b/src/PptMcp.Core/Commands/PageSetup/IPageSetupCommands.cs new file mode 100644 index 00000000..ab1a9376 --- /dev/null +++ b/src/PptMcp.Core/Commands/PageSetup/IPageSetupCommands.cs @@ -0,0 +1,27 @@ +using PptMcp.ComInterop.Session; +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.PageSetup; + +/// +/// Slide size and page setup operations. +/// +[ServiceCategory("pagesetup")] +[McpTool("pagesetup", Title = "Page Setup", Destructive = true, Category = "pagesetup")] +public interface IPageSetupCommands +{ + /// Get the current slide size and orientation. + [ServiceAction("get")] + PageSetupResult GetInfo(IPptBatch batch); + + /// + /// Set the slide size. Common sizes: 0=OnScreen (4:3), 1=LetterPaper, 2=Overhead, + /// 3=A3, 4=A4, 5=B4ISO, 6=B5ISO, 7=35mm, 8=Custom, 9=OnScreen16x9, 10=OnScreen16x10. + /// + /// Batch context + /// Slide width in points (1 inch = 72 points). 0 = don't change. + /// Slide height in points. 0 = don't change. + [ServiceAction("set-size")] + OperationResult SetSize(IPptBatch batch, float slideWidth, float slideHeight); +} diff --git a/src/PptMcp.Core/Commands/PageSetup/PageSetupCommands.cs b/src/PptMcp.Core/Commands/PageSetup/PageSetupCommands.cs new file mode 100644 index 00000000..4f0e1a9b --- /dev/null +++ b/src/PptMcp.Core/Commands/PageSetup/PageSetupCommands.cs @@ -0,0 +1,59 @@ +using PptMcp.ComInterop; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.PageSetup; + +public class PageSetupCommands : IPageSetupCommands +{ + public PageSetupResult GetInfo(IPptBatch batch) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = ctx.Presentation; + dynamic pageSetup = pres.PageSetup; + try + { + return new PageSetupResult + { + Success = true, + FilePath = ctx.PresentationPath, + SlideWidth = Convert.ToSingle(pageSetup.SlideWidth), + SlideHeight = Convert.ToSingle(pageSetup.SlideHeight), + SlideOrientation = Convert.ToInt32(pageSetup.SlideOrientation), + NotesOrientation = Convert.ToInt32(pageSetup.NotesOrientation) + }; + } + finally + { + ComUtilities.Release(ref pageSetup!); + } + }); + } + + public OperationResult SetSize(IPptBatch batch, float slideWidth, float slideHeight) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = ctx.Presentation; + dynamic pageSetup = pres.PageSetup; + try + { + if (slideWidth > 0) pageSetup.SlideWidth = slideWidth; + if (slideHeight > 0) pageSetup.SlideHeight = slideHeight; + + return new OperationResult + { + Success = true, + Action = "set-size", + Message = $"Set slide size to {slideWidth}x{slideHeight} points", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref pageSetup!); + } + }); + } +} diff --git a/src/PptMcp.Core/Commands/Placeholder/IPlaceholderCommands.cs b/src/PptMcp.Core/Commands/Placeholder/IPlaceholderCommands.cs new file mode 100644 index 00000000..acb03373 --- /dev/null +++ b/src/PptMcp.Core/Commands/Placeholder/IPlaceholderCommands.cs @@ -0,0 +1,35 @@ +using PptMcp.ComInterop.Session; +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Placeholder; + +/// +/// Slide placeholder operations: list available placeholders, fill text. +/// +[ServiceCategory("placeholder")] +[McpTool("placeholder", Title = "Slide Placeholders", Destructive = true, Category = "placeholders")] +public interface IPlaceholderCommands +{ + /// List all placeholders on a slide with type and current text. + /// Batch context + /// 1-based slide index + [ServiceAction("list")] + PlaceholderListResult List(IPptBatch batch, int slideIndex); + + /// Set text content of a placeholder by index. + /// Batch context + /// 1-based slide index + /// 1-based placeholder index + /// Text to set + [ServiceAction("set-text")] + OperationResult SetText(IPptBatch batch, int slideIndex, int placeholderIndex, string text); + + /// Replace placeholder content with an image from a file path. + /// Batch context + /// 1-based slide index + /// 1-based placeholder index + /// Absolute path to the image file + [ServiceAction("set-image")] + OperationResult SetImage(IPptBatch batch, int slideIndex, int placeholderIndex, string imagePath); +} diff --git a/src/PptMcp.Core/Commands/Placeholder/PlaceholderCommands.cs b/src/PptMcp.Core/Commands/Placeholder/PlaceholderCommands.cs new file mode 100644 index 00000000..51b449bc --- /dev/null +++ b/src/PptMcp.Core/Commands/Placeholder/PlaceholderCommands.cs @@ -0,0 +1,168 @@ +using PptMcp.ComInterop; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Placeholder; + +public class PlaceholderCommands : IPlaceholderCommands +{ + public PlaceholderListResult List(IPptBatch batch, int slideIndex) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + try + { + var result = new PlaceholderListResult + { + Success = true, + FilePath = ctx.PresentationPath, + SlideIndex = slideIndex + }; + + dynamic placeholders = slide.Shapes.Placeholders; + try + { + int count = (int)placeholders.Count; + for (int i = 1; i <= count; i++) + { + dynamic ph = placeholders.Item(i); + try + { + int phType = Convert.ToInt32(ph.PlaceholderFormat.Type); + var info = new PlaceholderInfo + { + Index = i, + Name = ph.Name?.ToString() ?? "", + PlaceholderType = phType, + PlaceholderTypeName = GetPlaceholderTypeName(phType), + }; + + try + { + info.HasTextFrame = Convert.ToInt32(ph.HasTextFrame) != 0; + if (info.HasTextFrame) + { + info.Text = ph.TextFrame.TextRange.Text?.ToString(); + } + } + catch { info.HasTextFrame = false; } + + result.Placeholders.Add(info); + } + finally + { + ComUtilities.Release(ref ph!); + } + } + } + finally + { + ComUtilities.Release(ref placeholders!); + } + + return result; + } + finally + { + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult SetText(IPptBatch batch, int slideIndex, int placeholderIndex, string text) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic? ph = null; + try + { + ph = slide.Shapes.Placeholders.Item(placeholderIndex); + if (Convert.ToInt32(ph.HasTextFrame) == 0) + throw new InvalidOperationException($"Placeholder {placeholderIndex} on slide {slideIndex} does not have a text frame."); + + ph.TextFrame.TextRange.Text = text; + return new OperationResult + { + Success = true, + Action = "set-text", + Message = $"Set text on placeholder {placeholderIndex} (slide {slideIndex})", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (ph != null) ComUtilities.Release(ref ph!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult SetImage(IPptBatch batch, int slideIndex, int placeholderIndex, string imagePath) + { + if (!System.IO.File.Exists(imagePath)) + throw new FileNotFoundException($"Image file not found: {imagePath}"); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic? ph = null; + try + { + ph = slide.Shapes.Placeholders.Item(placeholderIndex); + + // Capture placeholder position and size + float left = (float)ph.Left; + float top = (float)ph.Top; + float width = (float)ph.Width; + float height = (float)ph.Height; + + // Delete the placeholder + ph.Delete(); + ComUtilities.Release(ref ph!); + ph = null; + + // Insert picture at the same position + // AddPicture(FileName, LinkToFile, SaveWithDocument, Left, Top, Width, Height) + // msoFalse = 0, msoTrue = -1 + dynamic pic = slide.Shapes.AddPicture(imagePath, 0, -1, left, top, width, height); + ComUtilities.Release(ref pic!); + + return new OperationResult + { + Success = true, + Action = "set-image", + Message = $"Replaced placeholder {placeholderIndex} on slide {slideIndex} with image", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (ph != null) ComUtilities.Release(ref ph!); + ComUtilities.Release(ref slide!); + } + }); + } + + private static string GetPlaceholderTypeName(int ppPlaceholderType) => ppPlaceholderType switch + { + 1 => "Title", + 2 => "Body", + 3 => "CenterTitle", + 4 => "Subtitle", + 5 => "DateAndTime", + 6 => "SlideNumber", + 7 => "Footer", + 8 => "Header", + 9 => "Object", + 10 => "Chart", + 11 => "OrgChart", + 12 => "Table", + 13 => "MediaClip", + 14 => "Bitmap", + 15 => "VerticalTitle", + 16 => "VerticalBody", + _ => $"Unknown({ppPlaceholderType})" + }; +} diff --git a/src/PptMcp.Core/Commands/Proofing/IProofingCommands.cs b/src/PptMcp.Core/Commands/Proofing/IProofingCommands.cs new file mode 100644 index 00000000..f787a0ee --- /dev/null +++ b/src/PptMcp.Core/Commands/Proofing/IProofingCommands.cs @@ -0,0 +1,42 @@ +using PptMcp.ComInterop.Session; +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Proofing; + +/// +/// Proofing and language operations: check spelling, get/set language for text. +/// +[ServiceCategory("proofing")] +[McpTool("proofing", Title = "Proofing & Language", Destructive = true, Category = "proofing")] +public interface IProofingCommands +{ + /// + /// Collect all unique words from the presentation text for spelling review. + /// Returns deduplicated words from all slides and shapes. + /// + /// Batch context + [ServiceAction("check-spelling")] + OperationResult CheckSpelling(IPptBatch batch); + + /// + /// Set the proofing language (LanguageID) for text in shapes. + /// Common MsoLanguageID values: 1033=English US, 2057=English UK, 1031=German, 1036=French, 1034=Spanish, 1040=Italian, 1041=Japanese, 2052=Chinese Simplified. + /// + /// Batch context + /// 0 for all slides, or specific 1-based slide index + /// Empty string for all shapes on slide, or specific shape name + /// MsoLanguageID value (e.g. 1033 for English US) + [ServiceAction("set-language")] + OperationResult SetLanguage(IPptBatch batch, int slideIndex, string shapeName, int languageId); + + /// + /// Get the proofing language (LanguageID) of text in a shape. + /// Returns the MsoLanguageID value and language name. + /// + /// Batch context + /// 1-based slide index + /// Shape name + [ServiceAction("get-language")] + OperationResult GetLanguage(IPptBatch batch, int slideIndex, string shapeName); +} diff --git a/src/PptMcp.Core/Commands/Proofing/ProofingCommands.cs b/src/PptMcp.Core/Commands/Proofing/ProofingCommands.cs new file mode 100644 index 00000000..30fa2cf6 --- /dev/null +++ b/src/PptMcp.Core/Commands/Proofing/ProofingCommands.cs @@ -0,0 +1,280 @@ +using PptMcp.ComInterop; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Proofing; + +public class ProofingCommands : IProofingCommands +{ + public OperationResult CheckSpelling(IPptBatch batch) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = ctx.Presentation; + var words = new HashSet(StringComparer.OrdinalIgnoreCase); + + dynamic slides = pres.Slides; + try + { + int slideCount = (int)slides.Count; + for (int i = 1; i <= slideCount; i++) + { + dynamic slide = slides.Item(i); + try + { + CollectWordsFromSlide(slide, words); + } + finally + { + ComUtilities.Release(ref slide!); + } + } + } + finally + { + ComUtilities.Release(ref slides!); + } + + var sorted = words.OrderBy(w => w, StringComparer.OrdinalIgnoreCase).ToList(); + return new OperationResult + { + Success = true, + Action = "check-spelling", + Message = $"Found {sorted.Count} unique words across all slides:\n{string.Join(", ", sorted)}", + FilePath = ctx.PresentationPath + }; + }); + } + + public OperationResult SetLanguage(IPptBatch batch, int slideIndex, string shapeName, int languageId) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = ctx.Presentation; + int affected = 0; + + if (slideIndex > 0) + { + dynamic slide = pres.Slides.Item(slideIndex); + try + { + affected += SetLanguageOnSlide(slide, shapeName, languageId); + } + finally + { + ComUtilities.Release(ref slide!); + } + } + else + { + dynamic slides = pres.Slides; + try + { + int slideCount = (int)slides.Count; + for (int i = 1; i <= slideCount; i++) + { + dynamic slide = slides.Item(i); + try + { + affected += SetLanguageOnSlide(slide, shapeName, languageId); + } + finally + { + ComUtilities.Release(ref slide!); + } + } + } + finally + { + ComUtilities.Release(ref slides!); + } + } + + string scope = slideIndex > 0 ? $"slide {slideIndex}" : "all slides"; + string shapeScope = string.IsNullOrEmpty(shapeName) ? "all shapes" : $"shape '{shapeName}'"; + return new OperationResult + { + Success = true, + Action = "set-language", + Message = $"Set language to {languageId} ({GetLanguageName(languageId)}) on {shapeScope} in {scope}. {affected} shape(s) updated.", + FilePath = ctx.PresentationPath + }; + }); + } + + public OperationResult GetLanguage(IPptBatch batch, int slideIndex, string shapeName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + try + { + if (Convert.ToInt32(shape.HasTextFrame) == 0) + throw new InvalidOperationException($"Shape '{shapeName}' does not have a text frame."); + + dynamic textFrame = shape.TextFrame; + dynamic textRange = textFrame.TextRange; + try + { + int langId = Convert.ToInt32(textRange.LanguageID); + return new OperationResult + { + Success = true, + Action = "get-language", + Message = $"Language on shape '{shapeName}' (slide {slideIndex}): {langId} ({GetLanguageName(langId)})", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref textRange!); + ComUtilities.Release(ref textFrame!); + } + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + private static void CollectWordsFromSlide(dynamic slide, HashSet words) + { + dynamic shapes = slide.Shapes; + try + { + int count = (int)shapes.Count; + for (int i = 1; i <= count; i++) + { + dynamic shape = shapes.Item(i); + try + { + if (Convert.ToInt32(shape.HasTextFrame) != 0) + { + dynamic textRange = shape.TextFrame.TextRange; + try + { + string text = textRange.Text?.ToString() ?? ""; + if (!string.IsNullOrWhiteSpace(text)) + { + foreach (string word in text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)) + { + words.Add(word); + } + } + } + finally + { + ComUtilities.Release(ref textRange!); + } + } + } + finally + { + ComUtilities.Release(ref shape!); + } + } + } + finally + { + ComUtilities.Release(ref shapes!); + } + } + + private static int SetLanguageOnSlide(dynamic slide, string shapeName, int languageId) + { + int affected = 0; + + if (!string.IsNullOrEmpty(shapeName)) + { + dynamic shape = slide.Shapes.Item(shapeName); + try + { + SetLanguageOnShape(shape, languageId); + affected = 1; + } + finally + { + ComUtilities.Release(ref shape!); + } + } + else + { + dynamic shapes = slide.Shapes; + try + { + int count = (int)shapes.Count; + for (int i = 1; i <= count; i++) + { + dynamic shape = shapes.Item(i); + try + { + if (Convert.ToInt32(shape.HasTextFrame) != 0) + { + SetLanguageOnShape(shape, languageId); + affected++; + } + } + finally + { + ComUtilities.Release(ref shape!); + } + } + } + finally + { + ComUtilities.Release(ref shapes!); + } + } + + return affected; + } + + private static void SetLanguageOnShape(dynamic shape, int languageId) + { + if (Convert.ToInt32(shape.HasTextFrame) == 0) + throw new InvalidOperationException($"Shape '{shape.Name}' does not have a text frame."); + + dynamic textFrame = shape.TextFrame; + dynamic textRange = textFrame.TextRange; + try + { + textRange.LanguageID = languageId; + } + finally + { + ComUtilities.Release(ref textRange!); + ComUtilities.Release(ref textFrame!); + } + } + + private static string GetLanguageName(int languageId) => languageId switch + { + 0 => "No Proofing", + 1033 => "English (US)", + 2057 => "English (UK)", + 1031 => "German", + 1036 => "French", + 1034 => "Spanish", + 1040 => "Italian", + 1041 => "Japanese", + 1042 => "Korean", + 2052 => "Chinese (Simplified)", + 1028 => "Chinese (Traditional)", + 1046 => "Portuguese (Brazil)", + 2070 => "Portuguese (Portugal)", + 1049 => "Russian", + 1025 => "Arabic", + 1037 => "Hebrew", + 1043 => "Dutch", + 1053 => "Swedish", + 1044 => "Norwegian (Bokmal)", + 1045 => "Polish", + 1055 => "Turkish", + _ => $"MsoLanguageID({languageId})" + }; +} diff --git a/src/ExcelMcp.Core/Commands/RenameNameRules.cs b/src/PptMcp.Core/Commands/RenameNameRules.cs similarity index 97% rename from src/ExcelMcp.Core/Commands/RenameNameRules.cs rename to src/PptMcp.Core/Commands/RenameNameRules.cs index 5b07479e..5e1e05bd 100644 --- a/src/ExcelMcp.Core/Commands/RenameNameRules.cs +++ b/src/PptMcp.Core/Commands/RenameNameRules.cs @@ -1,4 +1,4 @@ -namespace Sbroenne.ExcelMcp.Core.Commands; +namespace PptMcp.Core.Commands; /// /// Shared helpers for rename validation and normalization rules. diff --git a/src/PptMcp.Core/Commands/Section/ISectionCommands.cs b/src/PptMcp.Core/Commands/Section/ISectionCommands.cs new file mode 100644 index 00000000..555e3879 --- /dev/null +++ b/src/PptMcp.Core/Commands/Section/ISectionCommands.cs @@ -0,0 +1,46 @@ +using PptMcp.ComInterop.Session; +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Section; + +/// +/// Presentation section management: list, add, rename, delete, and move sections. +/// Sections group slides for easier navigation and organization. +/// +[ServiceCategory("section")] +[McpTool("section", Title = "Section Operations", Destructive = true, Category = "structure")] +public interface ISectionCommands +{ + /// + /// List all sections in the presentation with their slide ranges. + /// + [ServiceAction("list")] + SectionListResult List(IPptBatch batch); + + /// + /// Add a new section starting at a specific slide. + /// + /// Batch context + /// Name for the new section + /// 1-based slide index where the section starts + [ServiceAction("add")] + OperationResult Add(IPptBatch batch, string sectionName, int slideIndex); + + /// + /// Rename an existing section. + /// + /// Batch context + /// 1-based section index + /// New section name + [ServiceAction("rename")] + OperationResult Rename(IPptBatch batch, int sectionIndex, string newName); + + /// + /// Delete a section (slides are kept, only section marker is removed). + /// + /// Batch context + /// 1-based section index + [ServiceAction("delete")] + OperationResult Delete(IPptBatch batch, int sectionIndex); +} diff --git a/src/PptMcp.Core/Commands/Section/SectionCommands.cs b/src/PptMcp.Core/Commands/Section/SectionCommands.cs new file mode 100644 index 00000000..b54e4148 --- /dev/null +++ b/src/PptMcp.Core/Commands/Section/SectionCommands.cs @@ -0,0 +1,142 @@ +using PptMcp.ComInterop; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Section; + +public class SectionCommands : ISectionCommands +{ + public SectionListResult List(IPptBatch batch) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = (dynamic)ctx.Presentation; + dynamic sections = pres.SectionProperties; + try + { + int count = Convert.ToInt32(sections.Count); + + var result = new SectionListResult + { + Success = true, + FilePath = ctx.PresentationPath + }; + + for (int i = 1; i <= count; i++) + { + string name = sections.Name(i)?.ToString() ?? $"Section {i}"; + int firstSlide = Convert.ToInt32(sections.FirstSlide(i)); + int slideCount = Convert.ToInt32(sections.SlidesCount(i)); + + result.Sections.Add(new SectionInfo + { + Index = i, + Name = name, + FirstSlideIndex = firstSlide, + SlideCount = slideCount + }); + } + + return result; + } + finally + { + ComUtilities.Release(ref sections!); + } + }); + } + + public OperationResult Add(IPptBatch batch, string sectionName, int slideIndex) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sectionName); + + return batch.Execute((ctx, ct) => + { + dynamic pres = (dynamic)ctx.Presentation; + dynamic sections = pres.SectionProperties; + try + { + // Find the section that contains slideIndex (or use count+1 to append) + int count = Convert.ToInt32(sections.Count); + int insertAtSection = count + 1; // default: append after last section + + // Find which section slideIndex falls into, so we insert before that section + for (int i = 1; i <= count; i++) + { + int firstSlide = Convert.ToInt32(sections.FirstSlide(i)); + if (firstSlide >= slideIndex) + { + insertAtSection = i; + break; + } + } + + // AddSection(sectionIndex, name) — inserts a new section at sectionIndex + int newIndex = Convert.ToInt32(sections.AddSection(insertAtSection, sectionName)); + + return new OperationResult + { + Success = true, + Action = "add", + Message = $"Added section '{sectionName}' at index {newIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref sections!); + } + }); + } + + public OperationResult Rename(IPptBatch batch, int sectionIndex, string newName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(newName); + + return batch.Execute((ctx, ct) => + { + dynamic pres = (dynamic)ctx.Presentation; + dynamic sections = pres.SectionProperties; + try + { + sections.Rename(sectionIndex, newName); + return new OperationResult + { + Success = true, + Action = "rename", + Message = $"Renamed section {sectionIndex} to '{newName}'", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref sections!); + } + }); + } + + public OperationResult Delete(IPptBatch batch, int sectionIndex) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = (dynamic)ctx.Presentation; + dynamic sections = pres.SectionProperties; + try + { + // Delete(sectionIndex, deleteSlides=false) + sections.Delete(sectionIndex, false); + return new OperationResult + { + Success = true, + Action = "delete", + Message = $"Deleted section {sectionIndex} (slides preserved)", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref sections!); + } + }); + } +} diff --git a/src/PptMcp.Core/Commands/Shape/IShapeCommands.cs b/src/PptMcp.Core/Commands/Shape/IShapeCommands.cs new file mode 100644 index 00000000..4c67e7e4 --- /dev/null +++ b/src/PptMcp.Core/Commands/Shape/IShapeCommands.cs @@ -0,0 +1,279 @@ +using PptMcp.ComInterop.Session; +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Shape; + +/// +/// Shape management: list, read, create, move, resize, delete, z-order. +/// +[ServiceCategory("shape")] +[McpTool("shape", Title = "Shape Operations", Destructive = true, Category = "shapes")] +public interface IShapeCommands +{ + /// + /// List all shapes on a slide. + /// + [ServiceAction("list")] + ShapeListResult List(IPptBatch batch, int slideIndex); + + /// + /// Get detailed info about a specific shape. + /// + [ServiceAction("read")] + ShapeDetailResult Read(IPptBatch batch, int slideIndex, string shapeName); + + /// + /// Add a textbox shape. + /// + /// Batch context + /// 1-based slide index + /// Position from left in points + /// Position from top in points + /// Width in points + /// Height in points + /// Initial text content + [ServiceAction("add-textbox")] + OperationResult AddTextbox(IPptBatch batch, int slideIndex, float left, float top, float width, float height, string text); + + /// + /// Add a rectangle, ellipse, or other auto-shape. + /// + /// Batch context + /// 1-based slide index + /// MsoAutoShapeType integer (1=Rectangle, 9=Oval, etc.) + /// Position from left in points + /// Position from top in points + /// Width in points + /// Height in points + [ServiceAction("add-shape")] + OperationResult AddShape(IPptBatch batch, int slideIndex, int autoShapeType, float left, float top, float width, float height); + + /// + /// Move and/or resize a shape. + /// + [ServiceAction("move-resize")] + OperationResult MoveResize(IPptBatch batch, int slideIndex, string shapeName, float? left, float? top, float? width, float? height); + + /// + /// Delete a shape by name. + /// + [ServiceAction("delete")] + OperationResult Delete(IPptBatch batch, int slideIndex, string shapeName); + + /// + /// Change the z-order of a shape (bring to front, send to back, etc.). + /// + /// Batch context + /// 1-based slide index + /// Name of the shape + /// 1=BringToFront, 2=SendToBack, 3=BringForward, 4=SendBackward + [ServiceAction("z-order")] + OperationResult ZOrder(IPptBatch batch, int slideIndex, string shapeName, int zOrderCmd); + + /// + /// Set the fill color of a shape. Use 'none' to remove fill (transparent). + /// + /// Batch context + /// 1-based slide index + /// Name of the shape + /// Hex color string like #FF0000 for red, or 'none' for no fill + [ServiceAction("set-fill")] + OperationResult SetFill(IPptBatch batch, int slideIndex, string shapeName, string colorHex); + + /// + /// Set the line/border color and width of a shape. Use 'none' to remove the line. + /// + /// Batch context + /// 1-based slide index + /// Name of the shape + /// Hex color like #000000 or 'none' to remove border + /// Line width in points (default 0.75) + [ServiceAction("set-line")] + OperationResult SetLine(IPptBatch batch, int slideIndex, string shapeName, string colorHex, float lineWidth); + + /// + /// Set the rotation angle of a shape in degrees. + /// + [ServiceAction("set-rotation")] + OperationResult SetRotation(IPptBatch batch, int slideIndex, string shapeName, float degrees); + + /// + /// Group multiple shapes into a single group shape. + /// + /// Batch context + /// 1-based slide index + /// Comma-separated list of shape names to group + [ServiceAction("group")] + OperationResult Group(IPptBatch batch, int slideIndex, string shapeNames); + + /// + /// Ungroup a group shape into individual shapes. + /// + [ServiceAction("ungroup")] + OperationResult Ungroup(IPptBatch batch, int slideIndex, string shapeName); + + /// + /// Set the alternative text (alt text) of a shape for accessibility. + /// + [ServiceAction("set-alt-text")] + OperationResult SetAltText(IPptBatch batch, int slideIndex, string shapeName, string altText); + + /// + /// Copy a shape to another slide. + /// + /// Batch context + /// 1-based source slide index + /// Name of the shape to copy + /// 1-based target slide index + [ServiceAction("copy-to-slide")] + OperationResult CopyToSlide(IPptBatch batch, int slideIndex, string shapeName, int targetSlideIndex); + + /// + /// Set shadow effect on a shape. Use visible=false to remove shadow. + /// + /// Batch context + /// 1-based slide index + /// Shape name + /// Show or hide shadow + /// Shadow offset X in points + /// Shadow offset Y in points + [ServiceAction("set-shadow")] + OperationResult SetShadow(IPptBatch batch, int slideIndex, string shapeName, bool visible, float offsetX, float offsetY); + + /// + /// Add a connector line between two shapes. + /// + /// Batch context + /// 1-based slide index + /// 1=Straight, 2=Elbow, 3=Curve + /// Starting shape name + /// Ending shape name + [ServiceAction("add-connector")] + OperationResult AddConnector(IPptBatch batch, int slideIndex, int connectorType, string startShapeName, string endShapeName); + + /// + /// Merge shapes using boolean operations. + /// mergeType: 1=Union, 2=Combine, 3=Fragment, 4=Intersect, 5=Subtract + /// + /// Batch context + /// 1-based slide index + /// Comma-separated shape names to merge + /// 1=Union, 2=Combine, 3=Fragment, 4=Intersect, 5=Subtract + [ServiceAction("merge")] + OperationResult MergeShapes(IPptBatch batch, int slideIndex, string shapeNames, int mergeType); + /// + /// Duplicate a shape on the same slide. + /// + /// Batch context + /// 1-based slide index + /// Name of the shape to duplicate + [ServiceAction("duplicate")] + OperationResult Duplicate(IPptBatch batch, int slideIndex, string shapeName); + + /// + /// Flip a shape horizontally or vertically. + /// + /// Batch context + /// 1-based slide index + /// Shape name + /// 0=Horizontal, 1=Vertical + [ServiceAction("flip")] + OperationResult Flip(IPptBatch batch, int slideIndex, string shapeName, int flipType); + + /// + /// Set TextFrame properties of a shape (margins, word wrap, auto size). + /// Margins are in points. Pass null to leave a property unchanged. + /// + /// Batch context + /// 1-based slide index + /// Name of the shape + /// Left margin in points (null = don't change) + /// Right margin in points (null = don't change) + /// Top margin in points (null = don't change) + /// Bottom margin in points (null = don't change) + /// Enable/disable word wrap (null = don't change) + /// 0=None, 1=ShapeToFitText, 2=TextToFitShape (null = don't change) + [ServiceAction("set-text-frame")] + OperationResult SetTextFrame(IPptBatch batch, int slideIndex, string shapeName, float? marginLeft, float? marginRight, float? marginTop, float? marginBottom, bool? wordWrap, int? autoSize); + + /// + /// Apply a two-color gradient fill to a shape. + /// + /// Batch context + /// 1-based slide index + /// Name of the shape + /// First gradient color as hex (#RRGGBB) + /// Second gradient color as hex (#RRGGBB) + /// 1=Horizontal, 2=Vertical, 3=DiagonalUp, 4=DiagonalDown, 5=FromCorner, 6=FromCenter + [ServiceAction("set-gradient-fill")] + OperationResult SetGradientFill(IPptBatch batch, int slideIndex, string shapeName, string color1, string color2, int gradientStyle); + + /// + /// Set glow effect on a shape. Use radius=0 to remove glow. + /// + /// Batch context + /// 1-based slide index + /// Name of the shape + /// Glow radius in points (0 = remove glow) + /// Glow color as hex (#RRGGBB) + [ServiceAction("set-glow")] + OperationResult SetGlow(IPptBatch batch, int slideIndex, string shapeName, float radius, string colorHex); + + /// + /// Set reflection effect on a shape. Use reflectionType=0 to remove reflection. + /// + /// Batch context + /// 1-based slide index + /// Name of the shape + /// 0=None, 1-9=msoReflectionType1 through msoReflectionType9 + [ServiceAction("set-reflection")] + OperationResult SetReflection(IPptBatch batch, int slideIndex, string shapeName, int reflectionType); + + /// + /// Set the opacity (transparency) of a shape's fill. + /// + /// Batch context + /// 1-based slide index + /// Name of the shape + /// Opacity value from 0.0 (fully transparent) to 1.0 (fully opaque) + [ServiceAction("set-opacity")] + OperationResult SetOpacity(IPptBatch batch, int slideIndex, string shapeName, float opacity); + + /// + /// Read the fill properties of a shape: fill type, color (if solid), and transparency. + /// + /// Batch context + /// 1-based slide index + /// Name of the shape + [ServiceAction("read-fill")] + OperationResult ReadFill(IPptBatch batch, int slideIndex, string shapeName); + + /// + /// Read the line/border properties of a shape: visible, color, weight. + /// + /// Batch context + /// 1-based slide index + /// Name of the shape + [ServiceAction("read-line")] + OperationResult ReadLine(IPptBatch batch, int slideIndex, string shapeName); + + /// + /// Find all shapes on a slide that match a given MsoShapeType. + /// + /// Batch context + /// 1-based slide index + /// MsoShapeType integer (1=AutoShape, 6=Group, 13=Picture, 14=Placeholder, 17=TextBox, etc.) + [ServiceAction("find-by-type")] + OperationResult FindByType(IPptBatch batch, int slideIndex, int shapeType); + + /// + /// Copy all formatting from one shape to another using Format Painter (PickUp/Apply). + /// + /// Batch context + /// 1-based slide index + /// Name of the shape to copy formatting from + /// Name of the shape to apply formatting to + [ServiceAction("copy-formatting")] + OperationResult CopyFormatting(IPptBatch batch, int slideIndex, string sourceShapeName, string targetShapeName); +} diff --git a/src/PptMcp.Core/Commands/Shape/ShapeCommands.cs b/src/PptMcp.Core/Commands/Shape/ShapeCommands.cs new file mode 100644 index 00000000..282675cc --- /dev/null +++ b/src/PptMcp.Core/Commands/Shape/ShapeCommands.cs @@ -0,0 +1,1071 @@ +using PptMcp.ComInterop; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Commands.Slide; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Shape; + +public class ShapeCommands : IShapeCommands +{ + public ShapeListResult List(IPptBatch batch, int slideIndex) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shapes = slide.Shapes; + try + { + int count = (int)shapes.Count; + + var result = new ShapeListResult + { + Success = true, + FilePath = ctx.PresentationPath, + SlideIndex = slideIndex + }; + + for (int i = 1; i <= count; i++) + { + dynamic shape = shapes.Item(i); + try + { + result.Shapes.Add(ShapeHelpers.ReadShapeInfo(shape)); + } + finally + { + ComUtilities.Release(ref shape!); + } + } + + return result; + } + finally + { + ComUtilities.Release(ref shapes!); + ComUtilities.Release(ref slide!); + } + }); + } + + public ShapeDetailResult Read(IPptBatch batch, int slideIndex, string shapeName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + try + { + return new ShapeDetailResult + { + Success = true, + FilePath = ctx.PresentationPath, + Shape = ShapeHelpers.ReadShapeInfo(shape) + }; + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult AddTextbox(IPptBatch batch, int slideIndex, float left, float top, float width, float height, string text) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + // msoTextOrientationHorizontal = 1 + dynamic shape = slide.Shapes.AddTextbox(1, left, top, width, height); + try + { + shape.TextFrame.TextRange.Text = text; + string name = shape.Name?.ToString() ?? ""; + return new OperationResult + { + Success = true, + Action = "add-textbox", + Message = $"Added textbox '{name}' on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult AddShape(IPptBatch batch, int slideIndex, int autoShapeType, float left, float top, float width, float height) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.AddShape(autoShapeType, left, top, width, height); + try + { + string name = shape.Name?.ToString() ?? ""; + return new OperationResult + { + Success = true, + Action = "add-shape", + Message = $"Added shape '{name}' (type {autoShapeType}) on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult MoveResize(IPptBatch batch, int slideIndex, string shapeName, float? left, float? top, float? width, float? height) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + try + { + if (left.HasValue) shape.Left = left.Value; + if (top.HasValue) shape.Top = top.Value; + if (width.HasValue) shape.Width = width.Value; + if (height.HasValue) shape.Height = height.Value; + + return new OperationResult + { + Success = true, + Action = "move-resize", + Message = $"Updated position/size of shape '{shapeName}' on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult Delete(IPptBatch batch, int slideIndex, string shapeName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + try + { + shape.Delete(); + return new OperationResult + { + Success = true, + Action = "delete", + Message = $"Deleted shape '{shapeName}' from slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult ZOrder(IPptBatch batch, int slideIndex, string shapeName, int zOrderCmd) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + try + { + shape.ZOrder(zOrderCmd); + return new OperationResult + { + Success = true, + Action = "z-order", + Message = $"Changed z-order of shape '{shapeName}' on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult SetFill(IPptBatch batch, int slideIndex, string shapeName, string colorHex) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + ArgumentException.ThrowIfNullOrWhiteSpace(colorHex); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + try + { + if (colorHex.Equals("none", StringComparison.OrdinalIgnoreCase)) + { + // msoFillBackground = 5 (transparent/no fill) + shape.Fill.Visible = 0; // msoFalse + } + else + { + shape.Fill.Visible = -1; // msoTrue + shape.Fill.Solid(); + shape.Fill.ForeColor.RGB = HexToOleColor(colorHex); + } + return new OperationResult + { + Success = true, + Action = "set-fill", + Message = $"Set fill of shape '{shapeName}' to '{colorHex}' on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult SetLine(IPptBatch batch, int slideIndex, string shapeName, string colorHex, float lineWidth) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + ArgumentException.ThrowIfNullOrWhiteSpace(colorHex); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + try + { + if (colorHex.Equals("none", StringComparison.OrdinalIgnoreCase)) + { + shape.Line.Visible = 0; // msoFalse + } + else + { + shape.Line.Visible = -1; // msoTrue + shape.Line.ForeColor.RGB = HexToOleColor(colorHex); + if (lineWidth > 0) + shape.Line.Weight = lineWidth; + } + return new OperationResult + { + Success = true, + Action = "set-line", + Message = $"Set line of shape '{shapeName}' to '{colorHex}' (weight {lineWidth}pt) on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult SetRotation(IPptBatch batch, int slideIndex, string shapeName, float degrees) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + try + { + shape.Rotation = degrees; + return new OperationResult + { + Success = true, + Action = "set-rotation", + Message = $"Rotated shape '{shapeName}' to {degrees}° on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult Group(IPptBatch batch, int slideIndex, string shapeNames) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeNames); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic? group = null; + try + { + // Select shapes by name, then group selection + string[] names = shapeNames.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + dynamic? first = null; + try + { + first = slide.Shapes.Item(names[0]); + first.Select(true); // Replace=true to start new selection + } + finally + { + if (first != null) ComUtilities.Release(ref first!); + } + for (int i = 1; i < names.Length; i++) + { + dynamic? s = null; + try + { + s = slide.Shapes.Item(names[i]); + s.Select(false); // Replace=false to add to selection + } + finally + { + if (s != null) ComUtilities.Release(ref s!); + } + } + group = slide.Shapes.SelectAll(); + // Actually we need to use the selection to group + dynamic app = ((dynamic)ctx.Presentation).Application; + dynamic? sel = null; + dynamic? grouped = null; + try + { + sel = app.ActiveWindow.Selection; + grouped = sel.ShapeRange.Group(); + string groupName = grouped.Name?.ToString() ?? ""; + return new OperationResult + { + Success = true, + Action = "group", + Message = $"Grouped {names.Length} shapes into '{groupName}' on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (grouped != null) ComUtilities.Release(ref grouped!); + if (sel != null) ComUtilities.Release(ref sel!); + } + } + finally + { + if (group != null) ComUtilities.Release(ref group!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult Ungroup(IPptBatch batch, int slideIndex, string shapeName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + try + { + shape.Ungroup(); + return new OperationResult + { + Success = true, + Action = "ungroup", + Message = $"Ungrouped shape '{shapeName}' on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult SetAltText(IPptBatch batch, int slideIndex, string shapeName, string altText) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + try + { + shape.AlternativeText = altText; + return new OperationResult + { + Success = true, + Action = "set-alt-text", + Message = $"Set alt text of shape '{shapeName}' to '{altText}' on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult CopyToSlide(IPptBatch batch, int slideIndex, string shapeName, int targetSlideIndex) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic pres = ctx.Presentation; + dynamic srcSlide = pres.Slides.Item(slideIndex); + dynamic shape = srcSlide.Shapes.Item(shapeName); + try + { + shape.Copy(); + dynamic targetSlide = pres.Slides.Item(targetSlideIndex); + dynamic pasted = targetSlide.Shapes.Paste(); + string newName = ""; + try { newName = pasted.Item(1).Name?.ToString() ?? ""; } catch { } + ComUtilities.Release(ref pasted!); + ComUtilities.Release(ref targetSlide!); + + return new OperationResult + { + Success = true, + Action = "copy-to-slide", + Message = $"Copied shape '{shapeName}' from slide {slideIndex} to slide {targetSlideIndex}" + + (string.IsNullOrEmpty(newName) ? "" : $" as '{newName}'"), + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref srcSlide!); + } + }); + } + + public OperationResult SetShadow(IPptBatch batch, int slideIndex, string shapeName, bool visible, float offsetX, float offsetY) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + try + { + dynamic shadow = shape.Shadow; + try + { + shadow.Visible = visible ? -1 : 0; + if (visible) + { + shadow.OffsetX = offsetX; + shadow.OffsetY = offsetY; + } + } + finally + { + ComUtilities.Release(ref shadow!); + } + + return new OperationResult + { + Success = true, + Action = "set-shadow", + Message = visible + ? $"Set shadow on shape '{shapeName}' (offset {offsetX},{offsetY})" + : $"Removed shadow from shape '{shapeName}'", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult AddConnector(IPptBatch batch, int slideIndex, int connectorType, string startShapeName, string endShapeName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(startShapeName); + ArgumentException.ThrowIfNullOrWhiteSpace(endShapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic? startShape = null; + dynamic? endShape = null; + dynamic? connector = null; + try + { + startShape = slide.Shapes.Item(startShapeName); + endShape = slide.Shapes.Item(endShapeName); + + // AddConnector(Type, BeginX, BeginY, EndX, EndY) + // Type: 1=msoConnectorStraight, 2=msoConnectorElbow, 3=msoConnectorCurve + float sx = Convert.ToSingle(startShape.Left) + Convert.ToSingle(startShape.Width) / 2; + float sy = Convert.ToSingle(startShape.Top) + Convert.ToSingle(startShape.Height) / 2; + float ex = Convert.ToSingle(endShape.Left) + Convert.ToSingle(endShape.Width) / 2; + float ey = Convert.ToSingle(endShape.Top) + Convert.ToSingle(endShape.Height) / 2; + + connector = slide.Shapes.AddConnector(connectorType, sx, sy, ex, ey); + dynamic cf = connector.ConnectorFormat; + try + { + cf.BeginConnect(startShape, 1); + cf.EndConnect(endShape, 1); + } + finally + { + ComUtilities.Release(ref cf!); + } + + string name = connector.Name?.ToString() ?? ""; + return new OperationResult + { + Success = true, + Action = "add-connector", + Message = $"Added connector '{name}' between '{startShapeName}' and '{endShapeName}' on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (connector != null) ComUtilities.Release(ref connector!); + if (endShape != null) ComUtilities.Release(ref endShape!); + if (startShape != null) ComUtilities.Release(ref startShape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult MergeShapes(IPptBatch batch, int slideIndex, string shapeNames, int mergeType) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeNames); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + try + { + string[] names = shapeNames.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (names.Length < 2) + throw new ArgumentException("At least 2 shape names are required for merge."); + + dynamic shapes = slide.Shapes; + try + { + object[] nameArray = names.Cast().ToArray(); + dynamic range = shapes.Range(nameArray); + try + { + // MergeShapes: 1=Union, 2=Combine, 3=Fragment, 4=Intersect, 5=Subtract + range.MergeShapes(mergeType); + + string mergeName = mergeType switch + { + 1 => "union", + 2 => "combine", + 3 => "fragment", + 4 => "intersect", + 5 => "subtract", + _ => $"type({mergeType})" + }; + + return new OperationResult + { + Success = true, + Action = "merge", + Message = $"Merged {names.Length} shapes using {mergeName} on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref range!); + } + } + finally + { + ComUtilities.Release(ref shapes!); + } + } + finally + { + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult Duplicate(IPptBatch batch, int slideIndex, string shapeName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + dynamic? dup = null; + try + { + dup = shape.Duplicate(); + // Duplicate returns a ShapeRange; get first item + dynamic newShape = dup.Item(1); + string newName = newShape.Name?.ToString() ?? ""; + ComUtilities.Release(ref newShape!); + + return new OperationResult + { + Success = true, + Action = "duplicate", + Message = $"Duplicated shape '{shapeName}' as '{newName}' on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (dup != null) ComUtilities.Release(ref dup!); + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult Flip(IPptBatch batch, int slideIndex, string shapeName, int flipType) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + try + { + // msoFlipHorizontal=0, msoFlipVertical=1 + shape.Flip(flipType); + string dir = flipType == 0 ? "horizontally" : "vertically"; + return new OperationResult + { + Success = true, + Action = "flip", + Message = $"Flipped shape '{shapeName}' {dir} on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult SetTextFrame(IPptBatch batch, int slideIndex, string shapeName, float? marginLeft, float? marginRight, float? marginTop, float? marginBottom, bool? wordWrap, int? autoSize) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + dynamic? textFrame = null; + try + { + textFrame = shape.TextFrame; + if (marginLeft.HasValue) textFrame.MarginLeft = marginLeft.Value; + if (marginRight.HasValue) textFrame.MarginRight = marginRight.Value; + if (marginTop.HasValue) textFrame.MarginTop = marginTop.Value; + if (marginBottom.HasValue) textFrame.MarginBottom = marginBottom.Value; + if (wordWrap.HasValue) textFrame.WordWrap = wordWrap.Value ? -1 : 0; // msoTrue=-1, msoFalse=0 + if (autoSize.HasValue) textFrame.AutoSize = autoSize.Value; // ppAutoSizeNone=0, ppAutoSizeShapeToFitText=1, ppAutoSizeTextToFitShape=2 + + return new OperationResult + { + Success = true, + Action = "set-text-frame", + Message = $"Updated text frame properties of shape '{shapeName}' on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (textFrame != null) ComUtilities.Release(ref textFrame!); + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult ReadFill(IPptBatch batch, int slideIndex, string shapeName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + dynamic? fill = null; + try + { + fill = shape.Fill; + // MsoFillType: 1=Solid, 3=Patterned, 4=Gradient, 6=Picture/Texture, 0=Background/None + int fillType = Convert.ToInt32(fill.Type); + string fillTypeName = fillType switch + { + 1 => "Solid", + 3 => "Patterned", + 4 => "Gradient", + 6 => "Picture", + _ => "None" + }; + + string colorHex = ""; + if (fillType == 1) + { + int rgb = Convert.ToInt32(fill.ForeColor.RGB); + int r = rgb & 0xFF; + int g = (rgb >> 8) & 0xFF; + int b = (rgb >> 16) & 0xFF; + colorHex = $"#{r:X2}{g:X2}{b:X2}"; + } + + float transparency = Convert.ToSingle(fill.Transparency); + + string message = fillType == 1 + ? $"Fill: {fillTypeName}, Color: {colorHex}, Transparency: {transparency:F2}" + : $"Fill: {fillTypeName}, Transparency: {transparency:F2}"; + + return new OperationResult + { + Success = true, + Action = "read-fill", + Message = message, + FilePath = ctx.PresentationPath + }; + } + finally + { + if (fill != null) ComUtilities.Release(ref fill!); + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult ReadLine(IPptBatch batch, int slideIndex, string shapeName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + dynamic? line = null; + try + { + line = shape.Line; + // msoTrue = -1, msoFalse = 0 + bool visible = Convert.ToInt32(line.Visible) != 0; + + string colorHex = ""; + float weight = 0f; + if (visible) + { + int rgb = Convert.ToInt32(line.ForeColor.RGB); + int r = rgb & 0xFF; + int g = (rgb >> 8) & 0xFF; + int b = (rgb >> 16) & 0xFF; + colorHex = $"#{r:X2}{g:X2}{b:X2}"; + weight = Convert.ToSingle(line.Weight); + } + + string message = visible + ? $"Visible: true, Color: {colorHex}, Weight: {weight:F2}pt" + : "Visible: false"; + + return new OperationResult + { + Success = true, + Action = "read-line", + Message = message, + FilePath = ctx.PresentationPath + }; + } + finally + { + if (line != null) ComUtilities.Release(ref line!); + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + private static int HexToOleColor(string hex) + { + hex = hex.TrimStart('#'); + if (hex.Length == 3) + hex = string.Concat(hex[0], hex[0], hex[1], hex[1], hex[2], hex[2]); + int r = Convert.ToInt32(hex[..2], 16); + int g = Convert.ToInt32(hex[2..4], 16); + int b = Convert.ToInt32(hex[4..6], 16); + return r | (g << 8) | (b << 16); + } + + public OperationResult SetGradientFill(IPptBatch batch, int slideIndex, string shapeName, string color1, string color2, int gradientStyle) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + ArgumentException.ThrowIfNullOrWhiteSpace(color1); + ArgumentException.ThrowIfNullOrWhiteSpace(color2); + + if (gradientStyle < 1 || gradientStyle > 6) + throw new ArgumentOutOfRangeException(nameof(gradientStyle), "gradientStyle must be 1-6 (1=Horizontal, 2=Vertical, 3=DiagonalUp, 4=DiagonalDown, 5=FromCorner, 6=FromCenter)"); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + try + { + // TwoColorGradient(style, variant) - variant 1 is default direction + shape.Fill.TwoColorGradient(gradientStyle, 1); + shape.Fill.ForeColor.RGB = HexToOleColor(color1); + shape.Fill.BackColor.RGB = HexToOleColor(color2); + + return new OperationResult + { + Success = true, + Action = "set-gradient-fill", + Message = $"Set gradient fill on shape '{shapeName}' from '{color1}' to '{color2}' (style {gradientStyle}) on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult SetGlow(IPptBatch batch, int slideIndex, string shapeName, float radius, string colorHex) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + ArgumentException.ThrowIfNullOrWhiteSpace(colorHex); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + try + { + dynamic glow = shape.Glow; + try + { + glow.Radius = radius; + if (radius > 0) + { + glow.Color.RGB = HexToOleColor(colorHex); + } + } + finally + { + ComUtilities.Release(ref glow!); + } + + return new OperationResult + { + Success = true, + Action = "set-glow", + Message = radius > 0 + ? $"Set glow on shape '{shapeName}' with radius {radius}pt and color '{colorHex}' on slide {slideIndex}" + : $"Removed glow from shape '{shapeName}' on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult SetReflection(IPptBatch batch, int slideIndex, string shapeName, int reflectionType) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + if (reflectionType < 0 || reflectionType > 9) + throw new ArgumentOutOfRangeException(nameof(reflectionType), "reflectionType must be 0-9 (0=None, 1-9=msoReflectionType1 through msoReflectionType9)"); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + try + { + dynamic reflection = shape.Reflection; + try + { + reflection.Type = reflectionType; + } + finally + { + ComUtilities.Release(ref reflection!); + } + + return new OperationResult + { + Success = true, + Action = "set-reflection", + Message = reflectionType > 0 + ? $"Set reflection type {reflectionType} on shape '{shapeName}' on slide {slideIndex}" + : $"Removed reflection from shape '{shapeName}' on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult SetOpacity(IPptBatch batch, int slideIndex, string shapeName, float opacity) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + if (opacity < 0.0f || opacity > 1.0f) + throw new ArgumentOutOfRangeException(nameof(opacity), "opacity must be between 0.0 (transparent) and 1.0 (opaque)"); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + try + { + // COM uses Transparency (0=opaque, 1=transparent), which is the inverse of opacity + shape.Fill.Transparency = 1.0f - opacity; + return new OperationResult + { + Success = true, + Action = "set-opacity", + Message = $"Set opacity of shape '{shapeName}' to {opacity:F2} on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult FindByType(IPptBatch batch, int slideIndex, int shapeType) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shapes = slide.Shapes; + try + { + int count = (int)shapes.Count; + var matches = new List(); + for (int i = 1; i <= count; i++) + { + dynamic shape = shapes.Item(i); + try + { + int type = Convert.ToInt32(shape.Type); + if (type == shapeType) + { + matches.Add(shape.Name?.ToString() ?? $"Shape{i}"); + } + } + finally + { + ComUtilities.Release(ref shape!); + } + } + + string typeName = ShapeHelpers.GetShapeTypeName(shapeType); + string message = matches.Count > 0 + ? $"Found {matches.Count} shape(s) of type {typeName} ({shapeType}): {string.Join(", ", matches)}" + : $"No shapes of type {typeName} ({shapeType}) found on slide {slideIndex}"; + + return new OperationResult + { + Success = true, + Action = "find-by-type", + Message = message, + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref shapes!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult CopyFormatting(IPptBatch batch, int slideIndex, string sourceShapeName, string targetShapeName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sourceShapeName); + ArgumentException.ThrowIfNullOrWhiteSpace(targetShapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic sourceShape = slide.Shapes.Item(sourceShapeName); + dynamic targetShape = slide.Shapes.Item(targetShapeName); + try + { + sourceShape.PickUp(); + targetShape.Apply(); + + return new OperationResult + { + Success = true, + Action = "copy-formatting", + Message = $"Copied formatting from '{sourceShapeName}' to '{targetShapeName}' on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref targetShape!); + ComUtilities.Release(ref sourceShape!); + ComUtilities.Release(ref slide!); + } + }); + } +} diff --git a/src/PptMcp.Core/Commands/ShapeAlign/IShapeAlignCommands.cs b/src/PptMcp.Core/Commands/ShapeAlign/IShapeAlignCommands.cs new file mode 100644 index 00000000..65a4d990 --- /dev/null +++ b/src/PptMcp.Core/Commands/ShapeAlign/IShapeAlignCommands.cs @@ -0,0 +1,35 @@ +using PptMcp.ComInterop.Session; +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.ShapeAlign; + +/// +/// Shape alignment and distribution operations. +/// +[ServiceCategory("shapealign")] +[McpTool("shapealign", Title = "Shape Alignment", Destructive = true, Category = "shapealign")] +public interface IShapeAlignCommands +{ + /// + /// Align shapes on a slide. + /// alignType: 0=AlignLeft, 1=AlignCenter, 2=AlignRight, 3=AlignTop, 4=AlignMiddle, 5=AlignBottom + /// + /// Batch context + /// 1-based slide index + /// Comma-separated shape names + /// Alignment type (0-5) + [ServiceAction("align")] + OperationResult Align(IPptBatch batch, int slideIndex, string shapeNames, int alignType); + + /// + /// Distribute shapes evenly on a slide. + /// distributeType: 0=Horizontally, 1=Vertically + /// + /// Batch context + /// 1-based slide index + /// Comma-separated shape names + /// 0=Horizontally, 1=Vertically + [ServiceAction("distribute")] + OperationResult Distribute(IPptBatch batch, int slideIndex, string shapeNames, int distributeType); +} diff --git a/src/PptMcp.Core/Commands/ShapeAlign/ShapeAlignCommands.cs b/src/PptMcp.Core/Commands/ShapeAlign/ShapeAlignCommands.cs new file mode 100644 index 00000000..c72069e4 --- /dev/null +++ b/src/PptMcp.Core/Commands/ShapeAlign/ShapeAlignCommands.cs @@ -0,0 +1,114 @@ +using PptMcp.ComInterop; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.ShapeAlign; + +public class ShapeAlignCommands : IShapeAlignCommands +{ + public OperationResult Align(IPptBatch batch, int slideIndex, string shapeNames, int alignType) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeNames); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + try + { + string[] names = shapeNames.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + dynamic shapes = slide.Shapes; + try + { + // Build a ShapeRange from the named shapes + object[] nameArray = names.Cast().ToArray(); + dynamic range = shapes.Range(nameArray); + try + { + // msoAlignLeft=0, msoAlignCenter=1, msoAlignRight=2, + // msoAlignTop=3, msoAlignMiddle=4, msoAlignBottom=5 + // RelativeTo: msoFalse=0 (relative to slide) + range.Align(alignType, 0); + + string alignName = GetAlignTypeName(alignType); + return new OperationResult + { + Success = true, + Action = "align", + Message = $"Aligned {names.Length} shape(s) {alignName} on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref range!); + } + } + finally + { + ComUtilities.Release(ref shapes!); + } + } + finally + { + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult Distribute(IPptBatch batch, int slideIndex, string shapeNames, int distributeType) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeNames); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + try + { + string[] names = shapeNames.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + dynamic shapes = slide.Shapes; + try + { + object[] nameArray = names.Cast().ToArray(); + dynamic range = shapes.Range(nameArray); + try + { + // msoDistributeHorizontally=0, msoDistributeVertically=1 + range.Distribute(distributeType, 0); + + string distName = distributeType == 0 ? "horizontally" : "vertically"; + return new OperationResult + { + Success = true, + Action = "distribute", + Message = $"Distributed {names.Length} shape(s) {distName} on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref range!); + } + } + finally + { + ComUtilities.Release(ref shapes!); + } + } + finally + { + ComUtilities.Release(ref slide!); + } + }); + } + + private static string GetAlignTypeName(int alignType) => alignType switch + { + 0 => "left", + 1 => "center", + 2 => "right", + 3 => "top", + 4 => "middle", + 5 => "bottom", + _ => $"type({alignType})" + }; +} diff --git a/src/PptMcp.Core/Commands/Slide/ISlideCommands.cs b/src/PptMcp.Core/Commands/Slide/ISlideCommands.cs new file mode 100644 index 00000000..2e113a55 --- /dev/null +++ b/src/PptMcp.Core/Commands/Slide/ISlideCommands.cs @@ -0,0 +1,120 @@ +using PptMcp.ComInterop.Session; +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Slide; + +/// +/// Slide lifecycle commands: list, read, create, duplicate, move, delete. +/// +[ServiceCategory("slide")] +[McpTool("slide", Title = "Slide Operations", Destructive = true, Category = "slides")] +public interface ISlideCommands +{ + /// + /// List all slides in the presentation with metadata. + /// + [ServiceAction("list")] + SlideListResult List(IPptBatch batch); + + /// + /// Get detailed information about a specific slide including all shapes. + /// + /// Batch context + /// 1-based slide index + [ServiceAction("read")] + SlideDetailResult Read(IPptBatch batch, int slideIndex); + + /// + /// Add a new slide at the specified position with a layout. + /// + /// Batch context + /// 1-based insert position (0 = at end) + /// Layout name from the slide master (e.g. "Title Slide", "Blank") + [ServiceAction("create")] + OperationResult Create(IPptBatch batch, int position, string layoutName); + + /// + /// Duplicate an existing slide. + /// + /// Batch context + /// 1-based index of slide to duplicate + [ServiceAction("duplicate")] + OperationResult Duplicate(IPptBatch batch, int slideIndex); + + /// + /// Move a slide to a new position. + /// + /// Batch context + /// 1-based index of slide to move + /// 1-based target position + [ServiceAction("move")] + OperationResult Move(IPptBatch batch, int slideIndex, int newPosition); + + /// + /// Delete a slide by index. + /// + /// Batch context + /// 1-based index of slide to delete + [ServiceAction("delete")] + OperationResult Delete(IPptBatch batch, int slideIndex); + + /// + /// Apply a layout to an existing slide. + /// + /// Batch context + /// 1-based slide index + /// Layout name from the slide master + [ServiceAction("apply-layout")] + OperationResult ApplyLayout(IPptBatch batch, int slideIndex, string layoutName); + + /// Set the name of a slide. + /// Batch context + /// 1-based slide index + /// New name for the slide + [ServiceAction("set-name")] + OperationResult SetName(IPptBatch batch, int slideIndex, string name); + + /// + /// Clone a slide multiple times and replace text placeholders in each clone. + /// + /// Batch context + /// 1-based index of the source slide to clone + /// Number of clones to create + /// Text to search for in each clone + /// Text to replace with in each clone + [ServiceAction("clone-with-replace")] + OperationResult CloneWithReplace(IPptBatch batch, int slideIndex, int count, string searchText, string replaceText); + + /// + /// Hide a slide so it is skipped during slideshow playback. + /// + /// Batch context + /// 1-based slide index + [ServiceAction("hide")] + OperationResult Hide(IPptBatch batch, int slideIndex); + + /// + /// Unhide a slide so it is included during slideshow playback. + /// + /// Batch context + /// 1-based slide index + [ServiceAction("unhide")] + OperationResult Unhide(IPptBatch batch, int slideIndex); + + /// + /// Export a slide as a PNG thumbnail to the specified file path. + /// + /// Batch context + /// 1-based slide index + /// Full path for the output PNG file + [ServiceAction("get-thumbnail")] + OperationResult GetThumbnail(IPptBatch batch, int slideIndex, string destinationPath); + + /// + /// Get a summary of the presentation including slide count, dimensions, and metadata. + /// + /// Batch context + [ServiceAction("summary")] + OperationResult Summary(IPptBatch batch); +} diff --git a/src/PptMcp.Core/Commands/Slide/ShapeHelpers.cs b/src/PptMcp.Core/Commands/Slide/ShapeHelpers.cs new file mode 100644 index 00000000..9af0d97b --- /dev/null +++ b/src/PptMcp.Core/Commands/Slide/ShapeHelpers.cs @@ -0,0 +1,123 @@ +using PptMcp.ComInterop; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Slide; + +/// +/// Helper methods for reading shape information from PowerPoint COM objects. +/// Shared between SlideCommands and ShapeCommands. +/// +internal static class ShapeHelpers +{ + /// + /// Read shape metadata from a PowerPoint COM Shape object. + /// + internal static ShapeInfo ReadShapeInfo(dynamic shape) + { + var info = new ShapeInfo + { + ShapeId = (int)shape.Id, + Name = shape.Name?.ToString() ?? "", + Left = Convert.ToSingle(shape.Left), + Top = Convert.ToSingle(shape.Top), + Width = Convert.ToSingle(shape.Width), + Height = Convert.ToSingle(shape.Height), + ZOrderPosition = (int)shape.ZOrderPosition, + }; + + // ShapeType is an MsoShapeType enum, use integer + int shapeType = Convert.ToInt32(shape.Type); + info.ShapeType = GetShapeTypeName(shapeType); + + try { info.AlternativeText = shape.AlternativeText?.ToString(); } catch { } + + // HasTextFrame + try + { + info.HasTextFrame = Convert.ToInt32(shape.HasTextFrame) != 0; // msoTrue = -1 + if (info.HasTextFrame) + { + try { info.Text = shape.TextFrame.TextRange.Text?.ToString(); } catch { } + } + } + catch { info.HasTextFrame = false; } + + // HasTable + try { info.HasTable = Convert.ToInt32(shape.HasTable) != 0; } catch { info.HasTable = false; } + + // HasChart + try { info.HasChart = Convert.ToInt32(shape.HasChart) != 0; } catch { info.HasChart = false; } + + // IsGroup (MsoShapeType.msoGroup = 6) + info.IsGroup = shapeType == 6; + + // IsPlaceholder (MsoShapeType.msoPlaceholder = 14) + info.IsPlaceholder = shapeType == 14; + if (info.IsPlaceholder) + { + try { info.PlaceholderType = Convert.ToInt32(shape.PlaceholderFormat.Type); } catch { } + } + + // Group items + if (info.IsGroup) + { + try + { + dynamic groupItems = shape.GroupItems; + int count = (int)groupItems.Count; + info.GroupItems = new List(count); + for (int i = 1; i <= count; i++) + { + dynamic child = groupItems.Item(i); + try + { + info.GroupItems.Add(ReadShapeInfo(child)); + } + finally + { + ComUtilities.Release(ref child!); + } + } + ComUtilities.Release(ref groupItems!); + } + catch { } + } + + return info; + } + + internal static string GetShapeTypeName(int msoShapeType) => msoShapeType switch + { + 1 => "AutoShape", + 2 => "Callout", + 3 => "Chart", + 4 => "Comment", + 5 => "FreeForm", + 6 => "Group", + 7 => "EmbeddedOLEObject", + 8 => "FormControl", + 9 => "Line", + 10 => "LinkedOLEObject", + 11 => "LinkedPicture", + 12 => "OLEControlObject", + 13 => "Picture", + 14 => "Placeholder", + 15 => "TextEffect", + 16 => "MediaObject", + 17 => "TextBox", + 19 => "Table", + 20 => "Canvas", + 21 => "Diagram", + 22 => "Ink", + 23 => "InkComment", + 24 => "SmartArt", + 25 => "Slicer", + 26 => "WebVideo", + 27 => "ContentApp", + 28 => "Graphic", + 29 => "LinkedGraphic", + 30 => "3DModel", + 31 => "Linked3DModel", + _ => $"Unknown({msoShapeType})" + }; +} diff --git a/src/PptMcp.Core/Commands/Slide/SlideCommands.cs b/src/PptMcp.Core/Commands/Slide/SlideCommands.cs new file mode 100644 index 00000000..74102e07 --- /dev/null +++ b/src/PptMcp.Core/Commands/Slide/SlideCommands.cs @@ -0,0 +1,577 @@ +using PptMcp.ComInterop; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Slide; + +public class SlideCommands : ISlideCommands +{ + public SlideListResult List(IPptBatch batch) + { + return batch.Execute((ctx, ct) => + { + var result = new SlideListResult { Success = true, FilePath = ctx.PresentationPath }; + dynamic pres = ctx.Presentation; + dynamic slides = pres.Slides; + try + { + int count = (int)slides.Count; + + for (int i = 1; i <= count; i++) + { + dynamic slide = slides.Item(i); + try + { + var info = new SlideInfo + { + SlideIndex = i, + SlideNumber = (int)slide.SlideNumber, + SlideId = slide.SlideID.ToString(), + ShapeCount = (int)slide.Shapes.Count, + }; + + try { info.LayoutName = slide.CustomLayout.Name?.ToString() ?? ""; } catch { info.LayoutName = ""; } + try { info.MasterName = slide.Design.SlideMaster.Name?.ToString() ?? ""; } catch { info.MasterName = ""; } + try { info.HasNotes = slide.NotesPage.Shapes.Placeholders.Item(2).TextFrame.TextRange.Text?.ToString()?.Length > 0; } catch { info.HasNotes = false; } + try { info.HasAnimations = (int)slide.TimeLine.MainSequence.Count > 0; } catch { info.HasAnimations = false; } + try { info.Name = slide.Name?.ToString(); } catch { } + + result.Slides.Add(info); + } + finally + { + ComUtilities.Release(ref slide!); + } + } + + return result; + } + finally + { + ComUtilities.Release(ref slides!); + } + }); + } + + public SlideDetailResult Read(IPptBatch batch, int slideIndex) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = ctx.Presentation; + dynamic slides = pres.Slides; + dynamic slide = slides.Item(slideIndex); + try + { + var result = new SlideDetailResult + { + Success = true, + FilePath = ctx.PresentationPath, + Slide = new SlideInfo + { + SlideIndex = slideIndex, + SlideNumber = (int)slide.SlideNumber, + SlideId = slide.SlideID.ToString(), + ShapeCount = (int)slide.Shapes.Count, + } + }; + + try { result.Slide.LayoutName = slide.CustomLayout.Name?.ToString() ?? ""; } catch { result.Slide.LayoutName = ""; } + try { result.Slide.MasterName = slide.Design.SlideMaster.Name?.ToString() ?? ""; } catch { result.Slide.MasterName = ""; } + try { result.Slide.Name = slide.Name?.ToString(); } catch { } + + dynamic shapes = slide.Shapes; + int shapeCount = (int)shapes.Count; + for (int i = 1; i <= shapeCount; i++) + { + dynamic shape = shapes.Item(i); + try + { + result.Shapes.Add(ShapeHelpers.ReadShapeInfo(shape)); + } + finally + { + ComUtilities.Release(ref shape!); + } + } + ComUtilities.Release(ref shapes!); + + return result; + } + finally + { + ComUtilities.Release(ref slide!); + ComUtilities.Release(ref slides!); + } + }); + } + + public OperationResult Create(IPptBatch batch, int position, string layoutName) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = ctx.Presentation; + dynamic slides = pres.Slides; + int slideCount = (int)slides.Count; + + // Find the layout by name + dynamic? layout = FindLayout(pres, layoutName); + if (layout == null) + throw new ArgumentException($"Layout '{layoutName}' not found in this presentation."); + + try + { + int insertAt = position <= 0 ? slideCount + 1 : position; + dynamic newSlide = slides.AddSlide(insertAt, layout); + int newIndex = (int)newSlide.SlideIndex; + ComUtilities.Release(ref newSlide!); + ComUtilities.Release(ref slides!); + + return new OperationResult + { + Success = true, + Action = "create", + Message = $"Created slide at position {newIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref layout!); + } + }); + } + + public OperationResult Duplicate(IPptBatch batch, int slideIndex) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = ctx.Presentation; + dynamic slides = pres.Slides; + dynamic slide = slides.Item(slideIndex); + try + { + dynamic duplicated = slide.Duplicate(); + // Duplicate returns a SlideRange; get first item + dynamic newSlide = duplicated.Item(1); + int newIndex = (int)newSlide.SlideIndex; + ComUtilities.Release(ref newSlide!); + ComUtilities.Release(ref duplicated!); + + return new OperationResult + { + Success = true, + Action = "duplicate", + Message = $"Duplicated slide {slideIndex} → new slide at position {newIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref slide!); + ComUtilities.Release(ref slides!); + } + }); + } + + public OperationResult Move(IPptBatch batch, int slideIndex, int newPosition) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = ctx.Presentation; + dynamic slides = pres.Slides; + dynamic slide = slides.Item(slideIndex); + try + { + slide.MoveTo(newPosition); + return new OperationResult + { + Success = true, + Action = "move", + Message = $"Moved slide from position {slideIndex} to {newPosition}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref slide!); + ComUtilities.Release(ref slides!); + } + }); + } + + public OperationResult Delete(IPptBatch batch, int slideIndex) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = ctx.Presentation; + dynamic slides = pres.Slides; + dynamic slide = slides.Item(slideIndex); + try + { + slide.Delete(); + return new OperationResult + { + Success = true, + Action = "delete", + Message = $"Deleted slide at position {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref slide!); + ComUtilities.Release(ref slides!); + } + }); + } + + public OperationResult ApplyLayout(IPptBatch batch, int slideIndex, string layoutName) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = ctx.Presentation; + dynamic slides = pres.Slides; + dynamic slide = slides.Item(slideIndex); + dynamic? layout = FindLayout(pres, layoutName); + + if (layout == null) + throw new ArgumentException($"Layout '{layoutName}' not found in this presentation."); + + try + { + slide.CustomLayout = layout; + return new OperationResult + { + Success = true, + Action = "apply-layout", + Message = $"Applied layout '{layoutName}' to slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref layout!); + ComUtilities.Release(ref slide!); + ComUtilities.Release(ref slides!); + } + }); + } + + public OperationResult SetName(IPptBatch batch, int slideIndex, string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + try + { + slide.Name = name; + return new OperationResult + { + Success = true, + Action = "set-name", + Message = $"Set name of slide {slideIndex} to '{name}'", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult CloneWithReplace(IPptBatch batch, int slideIndex, int count, string searchText, string replaceText) + { + ArgumentException.ThrowIfNullOrWhiteSpace(searchText); + ArgumentException.ThrowIfNullOrWhiteSpace(replaceText); + + return batch.Execute((ctx, ct) => + { + dynamic pres = ctx.Presentation; + dynamic slides = pres.Slides; + dynamic sourceSlide = slides.Item(slideIndex); + try + { + int created = 0; + for (int c = 0; c < count; c++) + { + dynamic duplicated = sourceSlide.Duplicate(); + dynamic newSlide = duplicated.Item(1); + try + { + dynamic shapes = newSlide.Shapes; + try + { + int shapeCount = (int)shapes.Count; + for (int i = 1; i <= shapeCount; i++) + { + dynamic shape = shapes.Item(i); + try + { + ReplaceTextInShape(shape, searchText, replaceText); + } + finally + { + ComUtilities.Release(ref shape!); + } + } + } + finally + { + ComUtilities.Release(ref shapes!); + } + + created++; + } + finally + { + ComUtilities.Release(ref newSlide!); + ComUtilities.Release(ref duplicated!); + } + } + + return new OperationResult + { + Success = true, + Action = "clone-with-replace", + Message = $"Created {created} clone(s) of slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref sourceSlide!); + ComUtilities.Release(ref slides!); + } + }); + } + + public OperationResult Hide(IPptBatch batch, int slideIndex) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + try + { + // msoTrue = -1 + slide.SlideShowTransition.Hidden = -1; + return new OperationResult + { + Success = true, + Action = "hide", + Message = $"Hidden slide {slideIndex} from slideshow", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult Unhide(IPptBatch batch, int slideIndex) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + try + { + // msoFalse = 0 + slide.SlideShowTransition.Hidden = 0; + return new OperationResult + { + Success = true, + Action = "unhide", + Message = $"Unhidden slide {slideIndex} for slideshow", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult GetThumbnail(IPptBatch batch, int slideIndex, string destinationPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath); + + return batch.Execute((ctx, ct) => + { + // Ensure destination directory exists + string? dir = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + try + { + slide.Export(destinationPath, "PNG", 320, 240); + return new OperationResult + { + Success = true, + Action = "get-thumbnail", + Message = $"Exported slide {slideIndex} thumbnail to '{destinationPath}'", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult Summary(IPptBatch batch) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = ctx.Presentation; + dynamic slides = pres.Slides; + dynamic pageSetup = pres.PageSetup; + try + { + int slideCount = (int)slides.Count; + float slideWidth = (float)pageSetup.SlideWidth; + float slideHeight = (float)pageSetup.SlideHeight; + + bool hasNotesMaster = false; + try { hasNotesMaster = Convert.ToInt32(pres.HasNotesMaster) != 0; } catch { } + + string templateName = ""; + try { templateName = pres.TemplateName?.ToString() ?? ""; } catch { } + + int totalShapes = 0; + for (int i = 1; i <= slideCount; i++) + { + dynamic slide = slides.Item(i); + try + { + totalShapes += (int)slide.Shapes.Count; + } + finally + { + ComUtilities.Release(ref slide!); + } + } + + var message = $"Slides: {slideCount}, Dimensions: {slideWidth}x{slideHeight}pt, " + + $"HasNotesMaster: {hasNotesMaster}, TemplateName: '{templateName}', " + + $"TotalShapes: {totalShapes}"; + + return new OperationResult + { + Success = true, + Action = "summary", + Message = message, + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref pageSetup!); + ComUtilities.Release(ref slides!); + } + }); + } + + /// + /// Replaces text in a shape, recursing into grouped shapes (Type == 6). + /// + private static void ReplaceTextInShape(dynamic shape, string searchText, string replaceText) + { + // msoGroup = 6 + if (Convert.ToInt32(shape.Type) == 6) + { + dynamic groupItems = shape.GroupItems; + try + { + int itemCount = (int)groupItems.Count; + for (int g = 1; g <= itemCount; g++) + { + dynamic groupChild = groupItems.Item(g); + try + { + ReplaceTextInShape(groupChild, searchText, replaceText); + } + finally + { + ComUtilities.Release(ref groupChild!); + } + } + } + finally + { + ComUtilities.Release(ref groupItems!); + } + return; + } + + if (Convert.ToInt32(shape.HasTextFrame) != 0) + { + dynamic textFrame = shape.TextFrame; + dynamic textRange = textFrame.TextRange; + try + { + string text = textRange.Text?.ToString() ?? ""; + if (text.Contains(searchText)) + { + textRange.Text = text.Replace(searchText, replaceText); + } + } + finally + { + ComUtilities.Release(ref textRange!); + ComUtilities.Release(ref textFrame!); + } + } + } + + private static dynamic? FindLayout(dynamic pres, string layoutName) + { + // PowerPoint COM: Presentation.Designs → Design.SlideMaster.CustomLayouts + dynamic designs = pres.Designs; + try + { + int designCount = (int)designs.Count; + + for (int d = 1; d <= designCount; d++) + { + dynamic design = designs.Item(d); + dynamic master = design.SlideMaster; + dynamic layouts = master.CustomLayouts; + try + { + int layoutCount = (int)layouts.Count; + + for (int l = 1; l <= layoutCount; l++) + { + dynamic layout = layouts.Item(l); + string name = layout.Name?.ToString() ?? ""; + if (string.Equals(name, layoutName, StringComparison.OrdinalIgnoreCase)) + { + return layout; + } + ComUtilities.Release(ref layout!); + } + } + finally + { + ComUtilities.Release(ref layouts!); + ComUtilities.Release(ref master!); + ComUtilities.Release(ref design!); + } + } + + return null; + } + finally + { + ComUtilities.Release(ref designs!); + } + } +} diff --git a/src/PptMcp.Core/Commands/SlideImport/ISlideImportCommands.cs b/src/PptMcp.Core/Commands/SlideImport/ISlideImportCommands.cs new file mode 100644 index 00000000..ca0284c3 --- /dev/null +++ b/src/PptMcp.Core/Commands/SlideImport/ISlideImportCommands.cs @@ -0,0 +1,23 @@ +using PptMcp.ComInterop.Session; +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.SlideImport; + +/// +/// Import slides from another presentation file. +/// +[ServiceCategory("slideimport")] +[McpTool("slideimport", Title = "Slide Import", Destructive = true, Category = "slideimport")] +public interface ISlideImportCommands +{ + /// + /// Import slides from another PowerPoint file. + /// + /// Batch context + /// Path to the source .pptx file + /// Comma-separated 1-based slide indices to import (empty = all) + /// Position to insert (0 = at end) + [ServiceAction("import")] + OperationResult ImportSlides(IPptBatch batch, string sourceFilePath, string slideIndices, int insertAt); +} diff --git a/src/PptMcp.Core/Commands/SlideImport/SlideImportCommands.cs b/src/PptMcp.Core/Commands/SlideImport/SlideImportCommands.cs new file mode 100644 index 00000000..a6c04d24 --- /dev/null +++ b/src/PptMcp.Core/Commands/SlideImport/SlideImportCommands.cs @@ -0,0 +1,65 @@ +using PptMcp.ComInterop; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.SlideImport; + +public class SlideImportCommands : ISlideImportCommands +{ + public OperationResult ImportSlides(IPptBatch batch, string sourceFilePath, string slideIndices, int insertAt) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sourceFilePath); + + return batch.Execute((ctx, ct) => + { + string fullPath = Path.GetFullPath(sourceFilePath); + if (!System.IO.File.Exists(fullPath)) + throw new FileNotFoundException($"Source file not found: '{fullPath}'"); + + dynamic pres = ctx.Presentation; + dynamic slides = pres.Slides; + try + { + int currentCount = (int)slides.Count; + int position = insertAt <= 0 ? currentCount : insertAt - 1; + + // InsertFromFile(FileName, Index, SlideStart, SlideEnd) + if (string.IsNullOrWhiteSpace(slideIndices)) + { + // Import all slides + slides.InsertFromFile(fullPath, position); + } + else + { + int[] indices = slideIndices.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(s => int.Parse(s, System.Globalization.CultureInfo.InvariantCulture)) + .OrderBy(i => i) + .ToArray(); + + // Import slide by slide (InsertFromFile with start/end) + int offset = 0; + foreach (int idx in indices) + { + slides.InsertFromFile(fullPath, position + offset, idx, idx); + offset++; + } + } + + int newCount = (int)slides.Count; + int imported = newCount - currentCount; + + return new OperationResult + { + Success = true, + Action = "import", + Message = $"Imported {imported} slide(s) from '{Path.GetFileName(fullPath)}'", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref slides!); + } + }); + } +} diff --git a/src/PptMcp.Core/Commands/SlideTable/ISlideTableCommands.cs b/src/PptMcp.Core/Commands/SlideTable/ISlideTableCommands.cs new file mode 100644 index 00000000..0f1081ed --- /dev/null +++ b/src/PptMcp.Core/Commands/SlideTable/ISlideTableCommands.cs @@ -0,0 +1,161 @@ +using PptMcp.ComInterop.Session; +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.SlideTable; + +/// +/// Table shape operations: create, read, write cells, add/delete rows and columns, merge cells. +/// +[ServiceCategory("slidetable")] +[McpTool("slidetable", Title = "Table Operations", Destructive = true, Category = "tables")] +public interface ISlideTableCommands +{ + /// + /// Create a table shape on a slide. + /// + /// Batch context + /// 1-based slide index + /// Number of rows + /// Number of columns + /// Position from left in points + /// Position from top in points + /// Width in points + /// Height in points + [ServiceAction("create")] + OperationResult Create(IPptBatch batch, int slideIndex, int rows, int columns, float left, float top, float width, float height); + + /// + /// Read all data from a table shape. + /// + /// Batch context + /// 1-based slide index + /// Name of the table shape + [ServiceAction("read")] + SlideTableResult Read(IPptBatch batch, int slideIndex, string shapeName); + + /// + /// Write a value to a specific table cell. + /// + /// Batch context + /// 1-based slide index + /// Name of the table shape + /// 1-based row index + /// 1-based column index + /// Cell value to set + [ServiceAction("write-cell")] + OperationResult WriteCell(IPptBatch batch, int slideIndex, string shapeName, int row, int column, string value); + + /// + /// Add a row to the table. + /// + /// Batch context + /// 1-based slide index + /// Name of the table shape + /// 1-based position to insert (-1 = at end) + [ServiceAction("add-row")] + OperationResult AddRow(IPptBatch batch, int slideIndex, string shapeName, int position); + + /// + /// Add a column to the table. + /// + /// Batch context + /// 1-based slide index + /// Name of the table shape + /// 1-based position to insert (-1 = at end) + [ServiceAction("add-column")] + OperationResult AddColumn(IPptBatch batch, int slideIndex, string shapeName, int position); + + /// + /// Delete a row from the table. + /// + /// Batch context + /// 1-based slide index + /// Name of the table shape + /// 1-based row index to delete + [ServiceAction("delete-row")] + OperationResult DeleteRow(IPptBatch batch, int slideIndex, string shapeName, int row); + + /// + /// Delete a column from the table. + /// + /// Batch context + /// 1-based slide index + /// Name of the table shape + /// 1-based column index to delete + [ServiceAction("delete-column")] + OperationResult DeleteColumn(IPptBatch batch, int slideIndex, string shapeName, int column); + + /// + /// Merge a range of cells in a table. + /// + /// Batch context + /// 1-based slide index + /// Name of the table shape + /// 1-based start row + /// 1-based start column + /// 1-based end row + /// 1-based end column + [ServiceAction("merge-cells")] + OperationResult MergeCells(IPptBatch batch, int slideIndex, string shapeName, int startRow, int startColumn, int endRow, int endColumn); + + /// + /// Read the text value of a specific table cell. + /// + /// Batch context + /// 1-based slide index + /// Name of the table shape + /// 1-based row index + /// 1-based column index + [ServiceAction("read-cell")] + OperationResult ReadCell(IPptBatch batch, int slideIndex, string shapeName, int row, int column); + + /// + /// Set formatting on a table cell (fill color, text alignment). + /// + /// Batch context + /// 1-based slide index + /// Name of the table shape + /// 1-based row index + /// 1-based column index + /// Hex fill color (#RRGGBB) or null to skip + /// Set bold (null = don't change) + /// Set font size (0 = don't change) + /// Text alignment: left, center, right (null = don't change) + [ServiceAction("format-cell")] + OperationResult FormatCell(IPptBatch batch, int slideIndex, string shapeName, int row, int column, string? fillColor, bool? fontBold, float fontSize, string? textAlign); + + /// + /// Write values to an entire row in a table. + /// + /// Batch context + /// 1-based slide index + /// Name of the table shape + /// 1-based row index + /// Comma-separated values for the row + [ServiceAction("write-row")] + OperationResult WriteRow(IPptBatch batch, int slideIndex, string shapeName, int row, string values); + + /// + /// Read all cell values from a specific row in a table. + /// + /// Batch context + /// 1-based slide index + /// Name of the table shape + /// 1-based row index + [ServiceAction("read-row")] + OperationResult ReadRow(IPptBatch batch, int slideIndex, string shapeName, int row); + + /// + /// Set all four borders of a table cell to the same color and width. + /// + /// Batch context + /// 1-based slide index + /// Name of the table shape + /// 1-based row index + /// 1-based column index + /// Border color as hex (#RRGGBB) + /// Border width in points + [ServiceAction("set-cell-border")] + OperationResult SetCellBorder(IPptBatch batch, int slideIndex, string shapeName, int row, int column, string colorHex, float width); +} diff --git a/src/PptMcp.Core/Commands/SlideTable/SlideTableCommands.cs b/src/PptMcp.Core/Commands/SlideTable/SlideTableCommands.cs new file mode 100644 index 00000000..2983c544 --- /dev/null +++ b/src/PptMcp.Core/Commands/SlideTable/SlideTableCommands.cs @@ -0,0 +1,548 @@ +using PptMcp.ComInterop; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.SlideTable; + +public class SlideTableCommands : ISlideTableCommands +{ + public OperationResult Create(IPptBatch batch, int slideIndex, int rows, int columns, float left, float top, float width, float height) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.AddTable(rows, columns, left, top, width, height); + try + { + string name = shape.Name?.ToString() ?? ""; + return new OperationResult + { + Success = true, + Action = "create", + Message = $"Created table '{name}' ({rows}x{columns}) on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public SlideTableResult Read(IPptBatch batch, int slideIndex, string shapeName) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + dynamic? table = null; + try + { + table = shape.Table; + int rowCount = (int)table.Rows.Count; + int colCount = (int)table.Columns.Count; + + var data = new List>(); + for (int r = 1; r <= rowCount; r++) + { + var row = new List(); + for (int c = 1; c <= colCount; c++) + { + dynamic cell = table.Cell(r, c); + try + { + string? text = cell.Shape.TextFrame.TextRange.Text?.ToString(); + row.Add(text); + } + finally + { + ComUtilities.Release(ref cell!); + } + } + data.Add(row); + } + + return new SlideTableResult + { + Success = true, + FilePath = ctx.PresentationPath, + ShapeId = (int)shape.Id, + ShapeName = shape.Name?.ToString() ?? "", + RowCount = rowCount, + ColumnCount = colCount, + Data = data + }; + } + finally + { + if (table != null) ComUtilities.Release(ref table!); + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult WriteCell(IPptBatch batch, int slideIndex, string shapeName, int row, int column, string value) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + dynamic? table = null; + dynamic? cell = null; + try + { + table = shape.Table; + cell = table.Cell(row, column); + cell.Shape.TextFrame.TextRange.Text = value; + + return new OperationResult + { + Success = true, + Action = "write-cell", + Message = $"Set cell ({row},{column}) in table '{shapeName}' on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (cell != null) ComUtilities.Release(ref cell!); + if (table != null) ComUtilities.Release(ref table!); + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult AddRow(IPptBatch batch, int slideIndex, string shapeName, int position) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + dynamic? table = null; + dynamic? rows = null; + try + { + table = shape.Table; + rows = table.Rows; + int insertAt = position <= 0 ? (int)rows.Count : position; + rows.Add(insertAt); + + return new OperationResult + { + Success = true, + Action = "add-row", + Message = $"Added row at position {insertAt} in table '{shapeName}'", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (rows != null) ComUtilities.Release(ref rows!); + if (table != null) ComUtilities.Release(ref table!); + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult AddColumn(IPptBatch batch, int slideIndex, string shapeName, int position) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + dynamic? table = null; + dynamic? columns = null; + try + { + table = shape.Table; + columns = table.Columns; + int insertAt = position <= 0 ? (int)columns.Count : position; + columns.Add(insertAt); + + return new OperationResult + { + Success = true, + Action = "add-column", + Message = $"Added column at position {insertAt} in table '{shapeName}'", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (columns != null) ComUtilities.Release(ref columns!); + if (table != null) ComUtilities.Release(ref table!); + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult DeleteRow(IPptBatch batch, int slideIndex, string shapeName, int row) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + dynamic? table = null; + dynamic? targetRow = null; + try + { + table = shape.Table; + targetRow = table.Rows.Item(row); + targetRow.Delete(); + + return new OperationResult + { + Success = true, + Action = "delete-row", + Message = $"Deleted row {row} from table '{shapeName}'", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (targetRow != null) ComUtilities.Release(ref targetRow!); + if (table != null) ComUtilities.Release(ref table!); + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult DeleteColumn(IPptBatch batch, int slideIndex, string shapeName, int column) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + dynamic? table = null; + dynamic? targetCol = null; + try + { + table = shape.Table; + targetCol = table.Columns.Item(column); + targetCol.Delete(); + + return new OperationResult + { + Success = true, + Action = "delete-column", + Message = $"Deleted column {column} from table '{shapeName}'", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (targetCol != null) ComUtilities.Release(ref targetCol!); + if (table != null) ComUtilities.Release(ref table!); + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult MergeCells(IPptBatch batch, int slideIndex, string shapeName, int startRow, int startColumn, int endRow, int endColumn) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + dynamic? table = null; + dynamic? cell1 = null; + dynamic? cell2 = null; + try + { + table = shape.Table; + cell1 = table.Cell(startRow, startColumn); + cell2 = table.Cell(endRow, endColumn); + cell1.Merge(cell2); + + return new OperationResult + { + Success = true, + Action = "merge-cells", + Message = $"Merged cells ({startRow},{startColumn})-({endRow},{endColumn}) in table '{shapeName}'", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (cell2 != null) ComUtilities.Release(ref cell2!); + if (cell1 != null) ComUtilities.Release(ref cell1!); + if (table != null) ComUtilities.Release(ref table!); + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult ReadCell(IPptBatch batch, int slideIndex, string shapeName, int row, int column) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + dynamic? table = null; + dynamic? cell = null; + try + { + table = shape.Table; + cell = table.Cell(row, column); + string text = cell.Shape.TextFrame.TextRange.Text?.ToString() ?? ""; + + return new OperationResult + { + Success = true, + Action = "read-cell", + Message = text, + FilePath = ctx.PresentationPath + }; + } + finally + { + if (cell != null) ComUtilities.Release(ref cell!); + if (table != null) ComUtilities.Release(ref table!); + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult FormatCell(IPptBatch batch, int slideIndex, string shapeName, int row, int column, string? fillColor, bool? fontBold, float fontSize, string? textAlign) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + dynamic? table = null; + dynamic? cell = null; + try + { + table = shape.Table; + cell = table.Cell(row, column); + + if (!string.IsNullOrEmpty(fillColor)) + { + dynamic? fill = null; + try + { + fill = cell.Shape.Fill; + fill.Visible = -1; + fill.Solid(); + fill.ForeColor.RGB = HexToOleColor(fillColor); + } + finally + { + if (fill != null) ComUtilities.Release(ref fill!); + } + } + + if (fontBold.HasValue || fontSize > 0) + { + dynamic font = cell.Shape.TextFrame.TextRange.Font; + try + { + if (fontBold.HasValue) font.Bold = fontBold.Value ? -1 : 0; + if (fontSize > 0) font.Size = fontSize; + } + finally + { + ComUtilities.Release(ref font!); + } + } + + if (!string.IsNullOrEmpty(textAlign)) + { + int align = textAlign.ToLowerInvariant() switch + { + "left" => 1, + "center" => 2, + "right" => 3, + _ => 1 + }; + cell.Shape.TextFrame.TextRange.ParagraphFormat.Alignment = align; + } + + return new OperationResult + { + Success = true, + Action = "format-cell", + Message = $"Formatted cell ({row},{column}) in table '{shapeName}' on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (cell != null) ComUtilities.Release(ref cell!); + if (table != null) ComUtilities.Release(ref table!); + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult WriteRow(IPptBatch batch, int slideIndex, string shapeName, int row, string values) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + ArgumentException.ThrowIfNullOrWhiteSpace(values); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + dynamic? table = null; + try + { + table = shape.Table; + string[] parts = values.Split(','); + int colCount = (int)table.Columns.Count; + int writeCount = Math.Min(parts.Length, colCount); + + for (int c = 1; c <= writeCount; c++) + { + dynamic? cell = null; + try + { + cell = table.Cell(row, c); + cell.Shape.TextFrame.TextRange.Text = parts[c - 1].Trim(); + } + finally + { + if (cell != null) ComUtilities.Release(ref cell!); + } + } + + return new OperationResult + { + Success = true, + Action = "write-row", + Message = $"Wrote {writeCount} values to row {row} in table '{shapeName}' on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (table != null) ComUtilities.Release(ref table!); + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult ReadRow(IPptBatch batch, int slideIndex, string shapeName, int row) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + dynamic? table = null; + try + { + table = shape.Table; + int colCount = (int)table.Columns.Count; + var cellValues = new List(); + + for (int c = 1; c <= colCount; c++) + { + dynamic? cell = null; + try + { + cell = table.Cell(row, c); + string text = cell.Shape.TextFrame.TextRange.Text?.ToString() ?? ""; + cellValues.Add(text); + } + finally + { + if (cell != null) ComUtilities.Release(ref cell!); + } + } + + return new OperationResult + { + Success = true, + Action = "read-row", + Message = string.Join(",", cellValues), + FilePath = ctx.PresentationPath + }; + } + finally + { + if (table != null) ComUtilities.Release(ref table!); + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult SetCellBorder(IPptBatch batch, int slideIndex, string shapeName, int row, int column, string colorHex, float width) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + ArgumentException.ThrowIfNullOrWhiteSpace(colorHex); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + dynamic? table = null; + dynamic? cell = null; + try + { + table = shape.Table; + cell = table.Cell(row, column); + int rgb = HexToOleColor(colorHex); + + // ppBorderTop=1, ppBorderLeft=2, ppBorderBottom=3, ppBorderRight=4 + for (int edge = 1; edge <= 4; edge++) + { + dynamic border = cell.Borders.Item(edge); + try + { + border.ForeColor.RGB = rgb; + border.Weight = width; + } + finally + { + ComUtilities.Release(ref border!); + } + } + + return new OperationResult + { + Success = true, + Action = "set-cell-border", + Message = $"Set borders on cell ({row},{column}) in table '{shapeName}' on slide {slideIndex} to color '{colorHex}' width {width}pt", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (cell != null) ComUtilities.Release(ref cell!); + if (table != null) ComUtilities.Release(ref table!); + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + private static int HexToOleColor(string hex) + { + hex = hex.TrimStart('#'); + if (hex.Length == 3) + hex = string.Concat(hex[0], hex[0], hex[1], hex[1], hex[2], hex[2]); + int r = Convert.ToInt32(hex[..2], 16); + int g = Convert.ToInt32(hex[2..4], 16); + int b = Convert.ToInt32(hex[4..6], 16); + return r | (g << 8) | (b << 16); + } +} diff --git a/src/PptMcp.Core/Commands/Slideshow/ISlideshowCommands.cs b/src/PptMcp.Core/Commands/Slideshow/ISlideshowCommands.cs new file mode 100644 index 00000000..f4a12f7f --- /dev/null +++ b/src/PptMcp.Core/Commands/Slideshow/ISlideshowCommands.cs @@ -0,0 +1,41 @@ +using PptMcp.ComInterop.Session; +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Slideshow; + +/// +/// Slideshow presentation mode: start, stop, navigate, get status. +/// +[ServiceCategory("slideshow")] +[McpTool("slideshow", Title = "Slideshow Operations", Destructive = false, Category = "slideshow")] +public interface ISlideshowCommands +{ + /// + /// Start the slideshow from a specific slide. + /// + /// Batch context + /// 1-based slide to start from (0 = beginning) + [ServiceAction("start")] + OperationResult Start(IPptBatch batch, int startSlide); + + /// + /// Stop/end the running slideshow. + /// + [ServiceAction("stop")] + OperationResult EndShow(IPptBatch batch); + + /// + /// Navigate to a specific slide in the running slideshow. + /// + /// Batch context + /// 1-based target slide index + [ServiceAction("goto-slide")] + OperationResult GotoSlide(IPptBatch batch, int slideIndex); + + /// + /// Get the current slideshow status. + /// + [ServiceAction("get-status")] + SlideshowInfoResult GetStatus(IPptBatch batch); +} diff --git a/src/PptMcp.Core/Commands/Slideshow/SlideshowCommands.cs b/src/PptMcp.Core/Commands/Slideshow/SlideshowCommands.cs new file mode 100644 index 00000000..2c1c16c8 --- /dev/null +++ b/src/PptMcp.Core/Commands/Slideshow/SlideshowCommands.cs @@ -0,0 +1,157 @@ +using PptMcp.ComInterop; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Slideshow; + +public class SlideshowCommands : ISlideshowCommands +{ + public OperationResult Start(IPptBatch batch, int startSlide) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = (dynamic)ctx.Presentation; + dynamic settings = pres.SlideShowSettings; + try + { + if (startSlide > 0) + { + settings.StartingSlide = startSlide; + settings.EndingSlide = (int)pres.Slides.Count; + } + + // ppShowTypeSpeaker = 1 (full screen) + settings.ShowType = 1; + dynamic window = settings.Run(); + ComUtilities.Release(ref window!); + + return new OperationResult + { + Success = true, + Action = "start", + Message = startSlide > 0 + ? $"Started slideshow from slide {startSlide}" + : "Started slideshow from beginning", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref settings!); + } + }); + } + + public OperationResult EndShow(IPptBatch batch) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = (dynamic)ctx.Presentation; + dynamic? window = null; + try + { + window = pres.SlideShowWindow; + dynamic? view = null; + try + { + view = window.View; + view.Exit(); + } + finally + { + if (view != null) ComUtilities.Release(ref view!); + } + + return new OperationResult + { + Success = true, + Action = "stop", + Message = "Stopped slideshow", + FilePath = ctx.PresentationPath + }; + } + catch + { + // No slideshow running + return new OperationResult + { + Success = true, + Action = "stop", + Message = "No slideshow was running", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (window != null) ComUtilities.Release(ref window!); + } + }); + } + + public OperationResult GotoSlide(IPptBatch batch, int slideIndex) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = (dynamic)ctx.Presentation; + dynamic window = pres.SlideShowWindow; + dynamic view = window.View; + try + { + view.GotoSlide(slideIndex); + return new OperationResult + { + Success = true, + Action = "goto-slide", + Message = $"Navigated to slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref view!); + ComUtilities.Release(ref window!); + } + }); + } + + public SlideshowInfoResult GetStatus(IPptBatch batch) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = (dynamic)ctx.Presentation; + int totalSlides = (int)pres.Slides.Count; + + bool isRunning = false; + int currentSlide = 0; + try + { + dynamic window = pres.SlideShowWindow; + dynamic? view = null; + try + { + view = window.View; + isRunning = true; + currentSlide = (int)view.CurrentShowPosition; + } + finally + { + if (view != null) ComUtilities.Release(ref view!); + ComUtilities.Release(ref window!); + } + } + catch + { + // No slideshow running + } + + return new SlideshowInfoResult + { + Success = true, + FilePath = ctx.PresentationPath, + IsRunning = isRunning, + CurrentSlide = currentSlide, + TotalSlides = totalSlides + }; + }); + } +} diff --git a/src/PptMcp.Core/Commands/SmartArt/ISmartArtCommands.cs b/src/PptMcp.Core/Commands/SmartArt/ISmartArtCommands.cs new file mode 100644 index 00000000..727e6ead --- /dev/null +++ b/src/PptMcp.Core/Commands/SmartArt/ISmartArtCommands.cs @@ -0,0 +1,28 @@ +using PptMcp.ComInterop.Session; +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.SmartArt; + +/// +/// SmartArt diagram operations: create, add/remove nodes, change layout. +/// +[ServiceCategory("smartart")] +[McpTool("smartart", Title = "SmartArt Diagrams", Destructive = true, Category = "smartart")] +public interface ISmartArtCommands +{ + /// Get SmartArt info from a shape. + /// Batch context + /// 1-based slide index + /// Name of the SmartArt shape + [ServiceAction("get-info")] + SmartArtInfoResult GetInfo(IPptBatch batch, int slideIndex, string shapeName); + + /// Add a text node to an existing SmartArt diagram. + /// Batch context + /// 1-based slide index + /// Name of the SmartArt shape + /// Text for the new node + [ServiceAction("add-node")] + OperationResult AddNode(IPptBatch batch, int slideIndex, string shapeName, string text); +} diff --git a/src/PptMcp.Core/Commands/SmartArt/SmartArtCommands.cs b/src/PptMcp.Core/Commands/SmartArt/SmartArtCommands.cs new file mode 100644 index 00000000..88105435 --- /dev/null +++ b/src/PptMcp.Core/Commands/SmartArt/SmartArtCommands.cs @@ -0,0 +1,126 @@ +using PptMcp.ComInterop; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.SmartArt; + +public class SmartArtCommands : ISmartArtCommands +{ + public SmartArtInfoResult GetInfo(IPptBatch batch, int slideIndex, string shapeName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + try + { + if (Convert.ToInt32(shape.HasSmartArt) == 0) + throw new InvalidOperationException($"Shape '{shapeName}' is not a SmartArt diagram."); + + dynamic smartArt = shape.SmartArt; + try + { + var result = new SmartArtInfoResult + { + Success = true, + FilePath = ctx.PresentationPath, + SlideIndex = slideIndex, + ShapeName = shapeName, + }; + + try { result.LayoutName = smartArt.Layout.Name?.ToString() ?? ""; } catch { } + + dynamic nodes = smartArt.AllNodes; + try + { + int count = (int)nodes.Count; + for (int i = 1; i <= count; i++) + { + dynamic node = nodes.Item(i); + try + { + string text = ""; + try { text = node.TextFrame2.TextRange.Text?.ToString() ?? ""; } catch { } + result.Nodes.Add(new SmartArtNodeInfo + { + Index = i, + Text = text, + Level = Convert.ToInt32(node.Level) + }); + } + finally + { + ComUtilities.Release(ref node!); + } + } + } + finally + { + ComUtilities.Release(ref nodes!); + } + + return result; + } + finally + { + ComUtilities.Release(ref smartArt!); + } + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult AddNode(IPptBatch batch, int slideIndex, string shapeName, string text) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + ArgumentException.ThrowIfNullOrWhiteSpace(text); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + try + { + if (Convert.ToInt32(shape.HasSmartArt) == 0) + throw new InvalidOperationException($"Shape '{shapeName}' is not a SmartArt diagram."); + + dynamic? smartArt = null; + dynamic? nodes = null; + dynamic? newNode = null; + try + { + smartArt = shape.SmartArt; + nodes = smartArt.AllNodes; + // AddNode() adds after the last node + newNode = nodes.Add(); + newNode.TextFrame2.TextRange.Text = text; + + return new OperationResult + { + Success = true, + Action = "add-node", + Message = $"Added node '{text}' to SmartArt '{shapeName}' on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (newNode != null) ComUtilities.Release(ref newNode!); + if (nodes != null) ComUtilities.Release(ref nodes!); + if (smartArt != null) ComUtilities.Release(ref smartArt!); + } + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } +} diff --git a/src/PptMcp.Core/Commands/Tag/ITagCommands.cs b/src/PptMcp.Core/Commands/Tag/ITagCommands.cs new file mode 100644 index 00000000..ec681809 --- /dev/null +++ b/src/PptMcp.Core/Commands/Tag/ITagCommands.cs @@ -0,0 +1,37 @@ +using PptMcp.ComInterop.Session; +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Tag; + +/// +/// Custom tags/metadata on slides and shapes. +/// +[ServiceCategory("tag")] +[McpTool("tag", Title = "Tags & Metadata", Destructive = true, Category = "tags")] +public interface ITagCommands +{ + /// List all tags on a slide or shape. + /// Batch context + /// 1-based slide index + /// Shape name (null/empty = slide-level tags) + [ServiceAction("list")] + TagListResult List(IPptBatch batch, int slideIndex, string? shapeName); + + /// Set a tag value on a slide or shape. + /// Batch context + /// 1-based slide index + /// Shape name (null/empty = slide-level tag) + /// Tag name (case-insensitive) + /// Tag value + [ServiceAction("set")] + OperationResult SetTag(IPptBatch batch, int slideIndex, string? shapeName, string tagName, string tagValue); + + /// Delete a tag from a slide or shape. + /// Batch context + /// 1-based slide index + /// Shape name (null/empty = slide-level tag) + /// Tag name to delete + [ServiceAction("delete")] + OperationResult DeleteTag(IPptBatch batch, int slideIndex, string? shapeName, string tagName); +} diff --git a/src/PptMcp.Core/Commands/Tag/TagCommands.cs b/src/PptMcp.Core/Commands/Tag/TagCommands.cs new file mode 100644 index 00000000..d0ccc7a2 --- /dev/null +++ b/src/PptMcp.Core/Commands/Tag/TagCommands.cs @@ -0,0 +1,145 @@ +using PptMcp.ComInterop; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Tag; + +public class TagCommands : ITagCommands +{ + public TagListResult List(IPptBatch batch, int slideIndex, string? shapeName) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + try + { + dynamic tags = GetTags(slide, shapeName); + try + { + var result = new TagListResult + { + Success = true, + FilePath = ctx.PresentationPath, + SlideIndex = slideIndex, + ShapeName = shapeName + }; + + int count = (int)tags.Count; + for (int i = 1; i <= count; i++) + { + result.Tags.Add(new TagInfo + { + Name = tags.Name(i)?.ToString() ?? "", + Value = tags.Value(i)?.ToString() ?? "" + }); + } + + return result; + } + finally + { + ComUtilities.Release(ref tags!); + } + } + finally + { + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult SetTag(IPptBatch batch, int slideIndex, string? shapeName, string tagName, string tagValue) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tagName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + try + { + dynamic tags = GetTags(slide, shapeName); + try + { + tags.Add(tagName, tagValue); + } + finally + { + ComUtilities.Release(ref tags!); + } + + string target = string.IsNullOrEmpty(shapeName) + ? $"slide {slideIndex}" + : $"shape '{shapeName}' on slide {slideIndex}"; + + return new OperationResult + { + Success = true, + Action = "set", + Message = $"Set tag '{tagName}' = '{tagValue}' on {target}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult DeleteTag(IPptBatch batch, int slideIndex, string? shapeName, string tagName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tagName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + try + { + dynamic tags = GetTags(slide, shapeName); + try + { + tags.Delete(tagName); + } + finally + { + ComUtilities.Release(ref tags!); + } + + string target = string.IsNullOrEmpty(shapeName) + ? $"slide {slideIndex}" + : $"shape '{shapeName}' on slide {slideIndex}"; + + return new OperationResult + { + Success = true, + Action = "delete", + Message = $"Deleted tag '{tagName}' from {target}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref slide!); + } + }); + } + + private static dynamic GetTags(dynamic slide, string? shapeName) + { + if (string.IsNullOrWhiteSpace(shapeName)) + return slide.Tags; + + dynamic shape = slide.Shapes.Item(shapeName); + try + { + dynamic tags = shape.Tags; + ComUtilities.Release(ref shape!); + return tags; + } + catch + { + ComUtilities.Release(ref shape!); + throw; + } + } +} diff --git a/src/PptMcp.Core/Commands/Text/ITextCommands.cs b/src/PptMcp.Core/Commands/Text/ITextCommands.cs new file mode 100644 index 00000000..3e2b1ece --- /dev/null +++ b/src/PptMcp.Core/Commands/Text/ITextCommands.cs @@ -0,0 +1,126 @@ +using PptMcp.ComInterop.Session; +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Text; + +/// +/// Text operations within shapes: get, set, format, find, replace. +/// +[ServiceCategory("text")] +[McpTool("text", Title = "Text Operations", Destructive = true, Category = "text")] +public interface ITextCommands +{ + /// + /// Get text content from a shape including paragraph and run details. + /// + [ServiceAction("get")] + TextResult GetText(IPptBatch batch, int slideIndex, string shapeName); + + /// + /// Set the text content of a shape (replaces all existing text). + /// + [ServiceAction("set")] + OperationResult SetText(IPptBatch batch, int slideIndex, string shapeName, string text); + + /// + /// Find text across all shapes in a slide or entire presentation. + /// + /// Batch context + /// Text to find + /// 0 for all slides, or specific 1-based index + [ServiceAction("find")] + OperationResult Find(IPptBatch batch, string searchText, int slideIndex); + + /// + /// Replace text across all shapes in a slide or entire presentation. + /// + /// Batch context + /// Text to find + /// Replacement text + /// 0 for all slides, or specific 1-based index + [ServiceAction("replace")] + OperationResult Replace(IPptBatch batch, string searchText, string replaceText, int slideIndex); + + /// + /// Format text in a shape (font, size, bold, italic, color, alignment). + /// Horizontal alignment: left, center, right, justify. + /// Vertical alignment: top, middle, bottom. + /// + [ServiceAction("format")] + OperationResult Format(IPptBatch batch, int slideIndex, string shapeName, string? fontName, float? fontSize, bool? bold, bool? italic, string? color, string? alignment, string? verticalAlignment); + + /// + /// Set advanced text formatting: underline, strikethrough, subscript, superscript. + /// + /// Batch context + /// 1-based slide index + /// Shape name + /// Set underline (null = don't change) + /// Set strikethrough (null = don't change) + /// Set subscript (null = don't change) + /// Set superscript (null = don't change) + [ServiceAction("format-advanced")] + OperationResult FormatAdvanced(IPptBatch batch, int slideIndex, string shapeName, bool? underline, bool? strikethrough, bool? subscript, bool? superscript); + + /// + /// Count words across all slides or a specific slide. + /// + /// Batch context + /// 0 for all slides, or specific 1-based index + [ServiceAction("word-count")] + OperationResult WordCount(IPptBatch batch, int slideIndex); + + /// + /// Report shapes missing alt text (AlternativeText). + /// + /// Batch context + /// 0 for all slides, or specific 1-based index + [ServiceAction("alt-text-audit")] + OperationResult AltTextAudit(IPptBatch batch, int slideIndex); + + /// + /// Find unfilled placeholders with empty text. + /// + /// Batch context + /// 0 for all slides, or specific 1-based index + [ServiceAction("empty-placeholder-audit")] + OperationResult EmptyPlaceholderAudit(IPptBatch batch, int slideIndex); + + /// + /// Set paragraph and character spacing for text in a shape. + /// + /// Batch context + /// 1-based slide index + /// Shape name + /// Line spacing in points (null = don't change) + /// Space before paragraph in points (null = don't change) + /// Space after paragraph in points (null = don't change) + /// Character spacing in points (null = don't change) + [ServiceAction("set-spacing")] + OperationResult SetSpacing(IPptBatch batch, int slideIndex, string shapeName, float? lineSpacing, float? spaceBefore, float? spaceAfter, float? characterSpacing); + + /// + /// Set bullet point style for text in a shape. + /// + /// Batch context + /// 1-based slide index + /// Shape name + /// 0=None, 1=Unnumbered (bullets), 2=Numbered + /// Custom bullet character (e.g. "•", "→") - only used when bulletType is 1 + /// Indent level 0-4 + [ServiceAction("set-bullets")] + OperationResult SetBullets(IPptBatch batch, int slideIndex, string shapeName, int bulletType, string? bulletCharacter, int indentLevel); + + /// + /// Insert a hyperlink on existing text within a shape. + /// Finds linkText within the shape's text and adds a hyperlink to it. + /// + /// Batch context + /// 1-based slide index + /// Shape name + /// Text to find and make into a hyperlink + /// URL for the hyperlink + [ServiceAction("insert-link")] + OperationResult InsertLink(IPptBatch batch, int slideIndex, string shapeName, string linkText, string url); +} diff --git a/src/PptMcp.Core/Commands/Text/TextCommands.cs b/src/PptMcp.Core/Commands/Text/TextCommands.cs new file mode 100644 index 00000000..55f83bdf --- /dev/null +++ b/src/PptMcp.Core/Commands/Text/TextCommands.cs @@ -0,0 +1,951 @@ +using PptMcp.ComInterop; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Text; + +public class TextCommands : ITextCommands +{ + public TextResult GetText(IPptBatch batch, int slideIndex, string shapeName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + try + { + var result = new TextResult + { + Success = true, + FilePath = ctx.PresentationPath, + ShapeId = (int)shape.Id, + ShapeName = shape.Name?.ToString() ?? "" + }; + + if (Convert.ToInt32(shape.HasTextFrame) == 0) + { + result.Text = ""; + return result; + } + + dynamic textFrame = shape.TextFrame; + dynamic textRange = textFrame.TextRange; + try + { + result.Text = textRange.Text?.ToString() ?? ""; + + ReadParagraphs(textRange, result.Paragraphs); + + return result; + } + finally + { + ComUtilities.Release(ref textRange!); + ComUtilities.Release(ref textFrame!); + } + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult SetText(IPptBatch batch, int slideIndex, string shapeName, string text) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + try + { + if (Convert.ToInt32(shape.HasTextFrame) == 0) + throw new InvalidOperationException($"Shape '{shapeName}' does not have a text frame."); + + shape.TextFrame.TextRange.Text = text; + return new OperationResult + { + Success = true, + Action = "set", + Message = $"Set text on shape '{shapeName}' (slide {slideIndex})", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult Find(IPptBatch batch, string searchText, int slideIndex) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = ctx.Presentation; + var matches = new List(); + + void SearchSlide(dynamic s, int idx) + { + dynamic shapes = s.Shapes; + try + { + int count = (int)shapes.Count; + for (int i = 1; i <= count; i++) + { + dynamic shape = shapes.Item(i); + try + { + if (Convert.ToInt32(shape.HasTextFrame) != 0) + { + string text = shape.TextFrame.TextRange.Text?.ToString() ?? ""; + if (text.Contains(searchText, StringComparison.OrdinalIgnoreCase)) + { + matches.Add($"Slide {idx}, Shape '{shape.Name}': found '{searchText}'"); + } + } + } + finally + { + ComUtilities.Release(ref shape!); + } + } + } + finally + { + ComUtilities.Release(ref shapes!); + } + } + + if (slideIndex > 0) + { + dynamic slide = pres.Slides.Item(slideIndex); + try + { + SearchSlide(slide, slideIndex); + } + finally + { + ComUtilities.Release(ref slide!); + } + } + else + { + dynamic slides = pres.Slides; + try + { + int slideCount = (int)slides.Count; + for (int i = 1; i <= slideCount; i++) + { + dynamic slide = slides.Item(i); + try + { + SearchSlide(slide, i); + } + finally + { + ComUtilities.Release(ref slide!); + } + } + } + finally + { + ComUtilities.Release(ref slides!); + } + } + + return new OperationResult + { + Success = true, + Action = "find", + Message = matches.Count > 0 + ? $"Found {matches.Count} match(es):\n" + string.Join("\n", matches) + : $"No matches found for '{searchText}'", + FilePath = ctx.PresentationPath + }; + }); + } + + public OperationResult Replace(IPptBatch batch, string searchText, string replaceText, int slideIndex) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = ctx.Presentation; + int replacements = 0; + + void ReplaceInSlide(dynamic s) + { + dynamic shapes = s.Shapes; + try + { + int count = (int)shapes.Count; + for (int i = 1; i <= count; i++) + { + dynamic shape = shapes.Item(i); + try + { + if (Convert.ToInt32(shape.HasTextFrame) != 0) + { + dynamic textRange = shape.TextFrame.TextRange; + try + { + string text = textRange.Text?.ToString() ?? ""; + if (text.Contains(searchText, StringComparison.OrdinalIgnoreCase)) + { + // Use Replace method via TextRange + dynamic found = textRange.Find(searchText); + while (found != null && Convert.ToInt32(found.Length) > 0) + { + found.Text = replaceText; + replacements++; + try + { + found = textRange.Find(searchText); + } + catch + { + break; + } + } + } + } + finally + { + ComUtilities.Release(ref textRange!); + } + } + } + finally + { + ComUtilities.Release(ref shape!); + } + } + } + finally + { + ComUtilities.Release(ref shapes!); + } + } + + if (slideIndex > 0) + { + dynamic slide = pres.Slides.Item(slideIndex); + try + { + ReplaceInSlide(slide); + } + finally + { + ComUtilities.Release(ref slide!); + } + } + else + { + dynamic slides = pres.Slides; + try + { + int slideCount = (int)slides.Count; + for (int i = 1; i <= slideCount; i++) + { + dynamic slide = slides.Item(i); + try + { + ReplaceInSlide(slide); + } + finally + { + ComUtilities.Release(ref slide!); + } + } + } + finally + { + ComUtilities.Release(ref slides!); + } + } + + return new OperationResult + { + Success = true, + Action = "replace", + Message = $"Replaced {replacements} occurrence(s) of '{searchText}' with '{replaceText}'", + FilePath = ctx.PresentationPath + }; + }); + } + + public OperationResult Format(IPptBatch batch, int slideIndex, string shapeName, string? fontName, float? fontSize, bool? bold, bool? italic, string? color, string? alignment, string? verticalAlignment) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + try + { + if (Convert.ToInt32(shape.HasTextFrame) == 0) + throw new InvalidOperationException($"Shape '{shapeName}' does not have a text frame."); + + dynamic textFrame = shape.TextFrame; + dynamic font = textFrame.TextRange.Font; + if (fontName != null) font.Name = fontName; + if (fontSize.HasValue) font.Size = fontSize.Value; + if (bold.HasValue) font.Bold = bold.Value ? -1 : 0; // msoTrue/msoFalse + if (italic.HasValue) font.Italic = italic.Value ? -1 : 0; + if (color != null) + { + // Parse hex color #RRGGBB → RGB int + if (color.StartsWith('#') && color.Length == 7) + { + int r = Convert.ToInt32(color[1..3], 16); + int g = Convert.ToInt32(color[3..5], 16); + int b = Convert.ToInt32(color[5..7], 16); + font.Color.RGB = r + (g << 8) + (b << 16); // PowerPoint uses BGR format + } + } + + // Horizontal alignment for all paragraphs + if (alignment != null) + { + // ppAlignLeft=1, ppAlignCenter=2, ppAlignRight=3, ppAlignJustify=4 + int ppAlign = alignment.ToLowerInvariant() switch + { + "left" => 1, + "center" => 2, + "right" => 3, + "justify" => 4, + _ => 1 + }; + dynamic paragraphs = textFrame.TextRange.Paragraphs(); + int paraCount = (int)paragraphs.Count; + for (int p = 1; p <= paraCount; p++) + { + dynamic para = textFrame.TextRange.Paragraphs(p, 1); + try { para.ParagraphFormat.Alignment = ppAlign; } + finally { ComUtilities.Release(ref para!); } + } + ComUtilities.Release(ref paragraphs!); + } + + // Vertical anchor: msoAnchorTop=1, msoAnchorMiddle=3, msoAnchorBottom=4 + if (verticalAlignment != null) + { + textFrame.VerticalAnchor = verticalAlignment.ToLowerInvariant() switch + { + "top" => 1, + "middle" => 3, + "bottom" => 4, + _ => 1 + }; + } + + ComUtilities.Release(ref font!); + ComUtilities.Release(ref textFrame!); + return new OperationResult + { + Success = true, + Action = "format", + Message = $"Formatted text in shape '{shapeName}' (slide {slideIndex})", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult FormatAdvanced(IPptBatch batch, int slideIndex, string shapeName, bool? underline, bool? strikethrough, bool? subscript, bool? superscript) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + try + { + if (Convert.ToInt32(shape.HasTextFrame) == 0) + throw new InvalidOperationException($"Shape '{shapeName}' does not have a text frame."); + + dynamic font = shape.TextFrame.TextRange.Font; + try + { + if (underline.HasValue) + font.Underline = underline.Value ? -1 : 0; + if (strikethrough.HasValue) + font.Strikethrough = strikethrough.Value ? -1 : 0; + if (subscript.HasValue) + font.Subscript = subscript.Value ? -1 : 0; + if (superscript.HasValue) + font.Superscript = superscript.Value ? -1 : 0; + } + finally + { + ComUtilities.Release(ref font!); + } + + return new OperationResult + { + Success = true, + Action = "format-advanced", + Message = $"Applied advanced formatting to shape '{shapeName}' (slide {slideIndex})", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult WordCount(IPptBatch batch, int slideIndex) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = ctx.Presentation; + int totalWords = 0; + + void CountInSlide(dynamic s) + { + dynamic shapes = s.Shapes; + try + { + int count = (int)shapes.Count; + for (int i = 1; i <= count; i++) + { + dynamic shape = shapes.Item(i); + try + { + if (Convert.ToInt32(shape.HasTextFrame) != 0) + { + dynamic textRange = shape.TextFrame.TextRange; + try + { + string text = textRange.Text?.ToString() ?? ""; + if (!string.IsNullOrWhiteSpace(text)) + { + totalWords += text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length; + } + } + finally + { + ComUtilities.Release(ref textRange!); + } + } + } + finally + { + ComUtilities.Release(ref shape!); + } + } + } + finally + { + ComUtilities.Release(ref shapes!); + } + } + + if (slideIndex > 0) + { + dynamic slide = pres.Slides.Item(slideIndex); + try + { + CountInSlide(slide); + } + finally + { + ComUtilities.Release(ref slide!); + } + } + else + { + dynamic slides = pres.Slides; + try + { + int slideCount = (int)slides.Count; + for (int i = 1; i <= slideCount; i++) + { + dynamic slide = slides.Item(i); + try + { + CountInSlide(slide); + } + finally + { + ComUtilities.Release(ref slide!); + } + } + } + finally + { + ComUtilities.Release(ref slides!); + } + } + + string scope = slideIndex > 0 ? $"slide {slideIndex}" : "all slides"; + return new OperationResult + { + Success = true, + Action = "word-count", + Message = $"Total word count ({scope}): {totalWords}", + FilePath = ctx.PresentationPath + }; + }); + } + + public OperationResult AltTextAudit(IPptBatch batch, int slideIndex) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = ctx.Presentation; + var missing = new List(); + + void AuditSlide(dynamic s, int idx) + { + dynamic shapes = s.Shapes; + try + { + int count = (int)shapes.Count; + for (int i = 1; i <= count; i++) + { + dynamic shape = shapes.Item(i); + try + { + string altText = shape.AlternativeText?.ToString() ?? ""; + if (string.IsNullOrWhiteSpace(altText)) + { + missing.Add($"Slide {idx}, Shape '{shape.Name}'"); + } + } + finally + { + ComUtilities.Release(ref shape!); + } + } + } + finally + { + ComUtilities.Release(ref shapes!); + } + } + + if (slideIndex > 0) + { + dynamic slide = pres.Slides.Item(slideIndex); + try + { + AuditSlide(slide, slideIndex); + } + finally + { + ComUtilities.Release(ref slide!); + } + } + else + { + dynamic slides = pres.Slides; + try + { + int slideCount = (int)slides.Count; + for (int i = 1; i <= slideCount; i++) + { + dynamic slide = slides.Item(i); + try + { + AuditSlide(slide, i); + } + finally + { + ComUtilities.Release(ref slide!); + } + } + } + finally + { + ComUtilities.Release(ref slides!); + } + } + + return new OperationResult + { + Success = true, + Action = "alt-text-audit", + Message = missing.Count > 0 + ? $"{missing.Count} shape(s) missing alt text:\n" + string.Join("\n", missing) + : "All shapes have alt text.", + FilePath = ctx.PresentationPath + }; + }); + } + + public OperationResult EmptyPlaceholderAudit(IPptBatch batch, int slideIndex) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = ctx.Presentation; + var empty = new List(); + + void AuditSlide(dynamic s, int idx) + { + dynamic placeholders = s.Shapes.Placeholders; + try + { + int count = (int)placeholders.Count; + for (int i = 1; i <= count; i++) + { + dynamic ph = placeholders.Item(i); + try + { + if (Convert.ToInt32(ph.HasTextFrame) != 0) + { + dynamic textRange = ph.TextFrame.TextRange; + try + { + string text = textRange.Text?.ToString() ?? ""; + if (string.IsNullOrWhiteSpace(text)) + { + empty.Add($"Slide {idx}, Placeholder '{ph.Name}'"); + } + } + finally + { + ComUtilities.Release(ref textRange!); + } + } + } + finally + { + ComUtilities.Release(ref ph!); + } + } + } + finally + { + ComUtilities.Release(ref placeholders!); + } + } + + if (slideIndex > 0) + { + dynamic slide = pres.Slides.Item(slideIndex); + try + { + AuditSlide(slide, slideIndex); + } + finally + { + ComUtilities.Release(ref slide!); + } + } + else + { + dynamic slides = pres.Slides; + try + { + int slideCount = (int)slides.Count; + for (int i = 1; i <= slideCount; i++) + { + dynamic slide = slides.Item(i); + try + { + AuditSlide(slide, i); + } + finally + { + ComUtilities.Release(ref slide!); + } + } + } + finally + { + ComUtilities.Release(ref slides!); + } + } + + return new OperationResult + { + Success = true, + Action = "empty-placeholder-audit", + Message = empty.Count > 0 + ? $"{empty.Count} empty placeholder(s) found:\n" + string.Join("\n", empty) + : "No empty placeholders found.", + FilePath = ctx.PresentationPath + }; + }); + } + + public OperationResult SetSpacing(IPptBatch batch, int slideIndex, string shapeName, float? lineSpacing, float? spaceBefore, float? spaceAfter, float? characterSpacing) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + try + { + if (Convert.ToInt32(shape.HasTextFrame) == 0) + throw new InvalidOperationException($"Shape '{shapeName}' does not have a text frame."); + + dynamic textFrame = shape.TextFrame; + dynamic textRange = textFrame.TextRange; + try + { + // Paragraph-level spacing + dynamic paragraphFormat = textRange.ParagraphFormat; + try + { + if (lineSpacing.HasValue) paragraphFormat.SpaceWithin = lineSpacing.Value; + if (spaceBefore.HasValue) paragraphFormat.SpaceBefore = spaceBefore.Value; + if (spaceAfter.HasValue) paragraphFormat.SpaceAfter = spaceAfter.Value; + } + finally + { + ComUtilities.Release(ref paragraphFormat!); + } + + // Character-level spacing + if (characterSpacing.HasValue) + { + dynamic font = textRange.Font; + try + { + font.Spacing = characterSpacing.Value; + } + finally + { + ComUtilities.Release(ref font!); + } + } + } + finally + { + ComUtilities.Release(ref textRange!); + ComUtilities.Release(ref textFrame!); + } + + return new OperationResult + { + Success = true, + Action = "set-spacing", + Message = $"Set spacing on shape '{shapeName}' (slide {slideIndex})", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult SetBullets(IPptBatch batch, int slideIndex, string shapeName, int bulletType, string? bulletCharacter, int indentLevel) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + try + { + if (Convert.ToInt32(shape.HasTextFrame) == 0) + throw new InvalidOperationException($"Shape '{shapeName}' does not have a text frame."); + + dynamic textFrame = shape.TextFrame; + dynamic textRange = textFrame.TextRange; + try + { + dynamic paragraphFormat = textRange.ParagraphFormat; + try + { + // ppBulletNone=0, ppBulletUnnumbered=1, ppBulletNumbered=2 + dynamic bullet = paragraphFormat.Bullet; + try + { + bullet.Type = bulletType; + + if (bulletType == 1 && !string.IsNullOrEmpty(bulletCharacter)) + bullet.Character = Convert.ToInt32(bulletCharacter[0]); + } + finally + { + ComUtilities.Release(ref bullet!); + } + + // ParagraphFormat.Level is 1-based (1-5) + paragraphFormat.Level = indentLevel + 1; + } + finally + { + ComUtilities.Release(ref paragraphFormat!); + } + } + finally + { + ComUtilities.Release(ref textRange!); + ComUtilities.Release(ref textFrame!); + } + + return new OperationResult + { + Success = true, + Action = "set-bullets", + Message = $"Set bullets on shape '{shapeName}' (slide {slideIndex})", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } + + /// + /// Read paragraph and run details from a COM TextRange into the provided list. + /// + private static void ReadParagraphs(dynamic textRange, List paragraphs) + { + dynamic allParagraphs = textRange.Paragraphs(); + try + { + int paraCount = (int)allParagraphs.Count; + for (int p = 1; p <= paraCount; p++) + { + dynamic para = textRange.Paragraphs(p, 1); + try + { + var paraInfo = new TextParagraphInfo + { + Index = p, + Text = para.Text?.ToString() ?? "" + }; + + try { paraInfo.Alignment = Convert.ToInt32(para.ParagraphFormat.Alignment); } catch { } + + dynamic runs = para.Runs(); + try + { + int runCount = (int)runs.Count; + for (int r = 1; r <= runCount; r++) + { + dynamic run = para.Runs(r, 1); + try + { + var runInfo = new TextRunInfo + { + Text = run.Text?.ToString() ?? "" + }; + try { runInfo.FontName = run.Font.Name?.ToString(); } catch { } + try { runInfo.FontSize = Convert.ToSingle(run.Font.Size); } catch { } + try { runInfo.Bold = Convert.ToInt32(run.Font.Bold) != 0; } catch { } + try { runInfo.Italic = Convert.ToInt32(run.Font.Italic) != 0; } catch { } + try + { + int rgb = Convert.ToInt32(run.Font.Color.RGB); + runInfo.Color = $"#{rgb:X6}"; + } + catch { } + + paraInfo.Runs.Add(runInfo); + } + finally + { + ComUtilities.Release(ref run!); + } + } + } + finally + { + ComUtilities.Release(ref runs!); + } + + paragraphs.Add(paraInfo); + } + finally + { + ComUtilities.Release(ref para!); + } + } + } + finally + { + ComUtilities.Release(ref allParagraphs!); + } + } + + public OperationResult InsertLink(IPptBatch batch, int slideIndex, string shapeName, string linkText, string url) + { + ArgumentException.ThrowIfNullOrWhiteSpace(shapeName); + ArgumentException.ThrowIfNullOrWhiteSpace(linkText); + ArgumentException.ThrowIfNullOrWhiteSpace(url); + + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + dynamic shape = slide.Shapes.Item(shapeName); + dynamic? textFrame = null; + dynamic? textRange = null; + dynamic? found = null; + dynamic? actionSettings = null; + dynamic? actionSetting = null; + dynamic? hyperlink = null; + try + { + textFrame = shape.TextFrame; + textRange = textFrame.TextRange; + found = textRange.Find(linkText); + + if (found == null) + { + return new OperationResult + { + Success = false, + ErrorMessage = $"Text '{linkText}' not found in shape '{shapeName}' on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + + // ppMouseClick = 1 + actionSettings = found.ActionSettings; + actionSetting = actionSettings.Item(1); + hyperlink = actionSetting.Hyperlink; + hyperlink.Address = url; + + return new OperationResult + { + Success = true, + Action = "insert-link", + Message = $"Added hyperlink '{url}' to text '{linkText}' in shape '{shapeName}' on slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (hyperlink != null) ComUtilities.Release(ref hyperlink!); + if (actionSetting != null) ComUtilities.Release(ref actionSetting!); + if (actionSettings != null) ComUtilities.Release(ref actionSettings!); + if (found != null) ComUtilities.Release(ref found!); + if (textRange != null) ComUtilities.Release(ref textRange!); + if (textFrame != null) ComUtilities.Release(ref textFrame!); + ComUtilities.Release(ref shape!); + ComUtilities.Release(ref slide!); + } + }); + } +} diff --git a/src/PptMcp.Core/Commands/Transition/ITransitionCommands.cs b/src/PptMcp.Core/Commands/Transition/ITransitionCommands.cs new file mode 100644 index 00000000..92ecfb2d --- /dev/null +++ b/src/PptMcp.Core/Commands/Transition/ITransitionCommands.cs @@ -0,0 +1,37 @@ +using PptMcp.ComInterop.Session; +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Transition; + +/// +/// Slide transition effects: get, set, remove. +/// +[ServiceCategory("transition")] +[McpTool("transition", Title = "Slide Transitions", Destructive = true, Category = "animation")] +public interface ITransitionCommands +{ + /// Get the transition settings for a slide. + [ServiceAction("get")] + TransitionResult GetTransition(IPptBatch batch, int slideIndex); + + /// Set a transition effect on a slide. + /// Batch context + /// 1-based slide index + /// PpEntryEffect enum value (e.g. 3844=Fade, 3849=Push) + /// Duration in seconds + /// Whether to advance on mouse click + /// Auto-advance after N seconds (0 = disabled) + [ServiceAction("set")] + OperationResult SetTransition(IPptBatch batch, int slideIndex, int transitionType, float duration, bool advanceOnClick, float advanceAfterTime); + + /// Remove transition from a slide. + [ServiceAction("remove")] + OperationResult Remove(IPptBatch batch, int slideIndex); + + /// Copy the transition from one slide to all other slides. + /// Batch context + /// 1-based index of the source slide + [ServiceAction("copy-to-all")] + OperationResult CopyToAll(IPptBatch batch, int slideIndex); +} diff --git a/src/PptMcp.Core/Commands/Transition/TransitionCommands.cs b/src/PptMcp.Core/Commands/Transition/TransitionCommands.cs new file mode 100644 index 00000000..0e77abe4 --- /dev/null +++ b/src/PptMcp.Core/Commands/Transition/TransitionCommands.cs @@ -0,0 +1,149 @@ +using PptMcp.ComInterop; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Transition; + +public class TransitionCommands : ITransitionCommands +{ + public TransitionResult GetTransition(IPptBatch batch, int slideIndex) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + try + { + dynamic trans = slide.SlideShowTransition; + return new TransitionResult + { + Success = true, + FilePath = ctx.PresentationPath, + SlideIndex = slideIndex, + TransitionType = Convert.ToInt32(trans.EntryEffect).ToString(), + Duration = Convert.ToSingle(trans.Duration), + AdvanceOnClick = Convert.ToInt32(trans.AdvanceOnClick) != 0, + AdvanceAfterTime = Convert.ToSingle(trans.AdvanceTime) + }; + } + finally + { + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult SetTransition(IPptBatch batch, int slideIndex, int transitionType, float duration, bool advanceOnClick, float advanceAfterTime) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + try + { + dynamic trans = slide.SlideShowTransition; + trans.EntryEffect = transitionType; + trans.Duration = duration; + trans.AdvanceOnClick = advanceOnClick ? -1 : 0; + + if (advanceAfterTime > 0) + { + trans.AdvanceOnTime = -1; // msoTrue + trans.AdvanceTime = advanceAfterTime; + } + else + { + trans.AdvanceOnTime = 0; // msoFalse + } + + return new OperationResult + { + Success = true, + Action = "set", + Message = $"Set transition on slide {slideIndex} (effect {transitionType}, {duration}s)", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult Remove(IPptBatch batch, int slideIndex) + { + return batch.Execute((ctx, ct) => + { + dynamic slide = ((dynamic)ctx.Presentation).Slides.Item(slideIndex); + try + { + // ppEffectNone = 0 + slide.SlideShowTransition.EntryEffect = 0; + return new OperationResult + { + Success = true, + Action = "remove", + Message = $"Removed transition from slide {slideIndex}", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref slide!); + } + }); + } + + public OperationResult CopyToAll(IPptBatch batch, int slideIndex) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = ctx.Presentation; + dynamic srcSlide = pres.Slides.Item(slideIndex); + dynamic slides = pres.Slides; + try + { + dynamic srcTrans = srcSlide.SlideShowTransition; + int effect = Convert.ToInt32(srcTrans.EntryEffect); + float duration = Convert.ToSingle(srcTrans.Duration); + int advClick = Convert.ToInt32(srcTrans.AdvanceOnClick); + int advTime = Convert.ToInt32(srcTrans.AdvanceOnTime); + float advSeconds = Convert.ToSingle(srcTrans.AdvanceTime); + + int count = (int)slides.Count; + int applied = 0; + for (int i = 1; i <= count; i++) + { + if (i == slideIndex) continue; + dynamic slide = slides.Item(i); + try + { + dynamic trans = slide.SlideShowTransition; + trans.EntryEffect = effect; + trans.Duration = duration; + trans.AdvanceOnClick = advClick; + trans.AdvanceOnTime = advTime; + if (advTime != 0) trans.AdvanceTime = advSeconds; + applied++; + } + finally + { + ComUtilities.Release(ref slide!); + } + } + + return new OperationResult + { + Success = true, + Action = "copy-to-all", + Message = $"Copied transition from slide {slideIndex} to {applied} other slide(s)", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref slides!); + ComUtilities.Release(ref srcSlide!); + } + }); + } +} diff --git a/src/PptMcp.Core/Commands/Vba/IVbaCommands.cs b/src/PptMcp.Core/Commands/Vba/IVbaCommands.cs new file mode 100644 index 00000000..0f3559c6 --- /dev/null +++ b/src/PptMcp.Core/Commands/Vba/IVbaCommands.cs @@ -0,0 +1,50 @@ +using PptMcp.ComInterop.Session; +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Vba; + +/// +/// VBA macro operations: list modules, view/import/delete code, run macros. +/// Requires VBA trust settings enabled in PowerPoint. +/// +[ServiceCategory("vba")] +[McpTool("vba", Title = "VBA Operations", Destructive = true, Category = "vba")] +public interface IVbaCommands +{ + /// + /// List all VBA modules in the presentation. + /// + [ServiceAction("list")] + VbaModuleListResult List(IPptBatch batch); + + /// + /// View the code of a specific VBA module. + /// + [ServiceAction("view")] + VbaModuleCodeResult View(IPptBatch batch, string moduleName); + + /// + /// Import a new VBA module from code text. + /// + /// Batch context + /// Name for the new module + /// VBA code to import + /// 1=Standard, 2=ClassModule (default: 1) + [ServiceAction("import")] + OperationResult Import(IPptBatch batch, string moduleName, string code, int moduleType); + + /// + /// Delete a VBA module. + /// + [ServiceAction("delete")] + OperationResult Delete(IPptBatch batch, string moduleName); + + /// + /// Run a VBA macro by name. + /// + /// Batch context + /// Fully qualified macro name (e.g., "Module1.MyMacro") + [ServiceAction("run")] + OperationResult Run(IPptBatch batch, string macroName); +} diff --git a/src/PptMcp.Core/Commands/Vba/VbaCommands.cs b/src/PptMcp.Core/Commands/Vba/VbaCommands.cs new file mode 100644 index 00000000..22425d8b --- /dev/null +++ b/src/PptMcp.Core/Commands/Vba/VbaCommands.cs @@ -0,0 +1,199 @@ +using PptMcp.ComInterop; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Vba; + +public class VbaCommands : IVbaCommands +{ + public VbaModuleListResult List(IPptBatch batch) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = (dynamic)ctx.Presentation; + dynamic vbProject = pres.VBProject; + dynamic components = vbProject.VBComponents; + try + { + int count = (int)components.Count; + var result = new VbaModuleListResult + { + Success = true, + FilePath = ctx.PresentationPath + }; + + for (int i = 1; i <= count; i++) + { + dynamic comp = components.Item(i); + try + { + int modType = (int)comp.Type; + int lineCount = 0; + try { lineCount = (int)comp.CodeModule.CountOfLines; } catch { } + + result.Modules.Add(new VbaModuleInfo + { + Name = comp.Name?.ToString() ?? "", + ModuleType = modType, + ModuleTypeName = GetModuleTypeName(modType), + LineCount = lineCount + }); + } + finally + { + ComUtilities.Release(ref comp!); + } + } + + return result; + } + finally + { + ComUtilities.Release(ref components!); + ComUtilities.Release(ref vbProject!); + } + }); + } + + public VbaModuleCodeResult View(IPptBatch batch, string moduleName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(moduleName); + + return batch.Execute((ctx, ct) => + { + dynamic pres = (dynamic)ctx.Presentation; + dynamic vbProject = pres.VBProject; + dynamic components = vbProject.VBComponents; + dynamic comp = components.Item(moduleName); + dynamic? codeModule = null; + try + { + codeModule = comp.CodeModule; + int lineCount = (int)codeModule.CountOfLines; + string code = lineCount > 0 + ? codeModule.Lines(1, lineCount)?.ToString() ?? "" + : ""; + + return new VbaModuleCodeResult + { + Success = true, + FilePath = ctx.PresentationPath, + ModuleName = moduleName, + Code = code, + LineCount = lineCount + }; + } + finally + { + if (codeModule != null) ComUtilities.Release(ref codeModule!); + ComUtilities.Release(ref comp!); + ComUtilities.Release(ref components!); + ComUtilities.Release(ref vbProject!); + } + }); + } + + public OperationResult Import(IPptBatch batch, string moduleName, string code, int moduleType) + { + ArgumentException.ThrowIfNullOrWhiteSpace(moduleName); + ArgumentException.ThrowIfNullOrWhiteSpace(code); + + return batch.Execute((ctx, ct) => + { + dynamic pres = (dynamic)ctx.Presentation; + dynamic vbProject = pres.VBProject; + dynamic components = vbProject.VBComponents; + dynamic? comp = null; + dynamic? codeModule = null; + try + { + // vbext_ct_StdModule = 1, vbext_ct_ClassModule = 2 + int vbType = moduleType == 2 ? 2 : 1; + comp = components.Add(vbType); + comp.Name = moduleName; + codeModule = comp.CodeModule; + codeModule.AddFromString(code); + + return new OperationResult + { + Success = true, + Action = "import", + Message = $"Imported VBA module '{moduleName}'", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (codeModule != null) ComUtilities.Release(ref codeModule!); + if (comp != null) ComUtilities.Release(ref comp!); + ComUtilities.Release(ref components!); + ComUtilities.Release(ref vbProject!); + } + }); + } + + public OperationResult Delete(IPptBatch batch, string moduleName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(moduleName); + + return batch.Execute((ctx, ct) => + { + dynamic pres = (dynamic)ctx.Presentation; + dynamic vbProject = pres.VBProject; + dynamic components = vbProject.VBComponents; + dynamic comp = components.Item(moduleName); + try + { + components.Remove(comp); + return new OperationResult + { + Success = true, + Action = "delete", + Message = $"Deleted VBA module '{moduleName}'", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref comp!); + ComUtilities.Release(ref components!); + ComUtilities.Release(ref vbProject!); + } + }); + } + + public OperationResult Run(IPptBatch batch, string macroName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(macroName); + + return batch.Execute((ctx, ct) => + { + // PowerPoint.Application.Run(macroName) + dynamic app = ((dynamic)ctx.Presentation).Application; + try + { + app.Run(macroName); + return new OperationResult + { + Success = true, + Action = "run", + Message = $"Executed macro '{macroName}'", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref app!); + } + }); + } + + private static string GetModuleTypeName(int moduleType) => moduleType switch + { + 1 => "Standard", + 2 => "ClassModule", + 3 => "MSForm", + 100 => "Document", + _ => $"Unknown({moduleType})" + }; +} diff --git a/src/PptMcp.Core/Commands/Window/IWindowCommands.cs b/src/PptMcp.Core/Commands/Window/IWindowCommands.cs new file mode 100644 index 00000000..570831f4 --- /dev/null +++ b/src/PptMcp.Core/Commands/Window/IWindowCommands.cs @@ -0,0 +1,59 @@ +using PptMcp.ComInterop.Session; +using PptMcp.Core.Attributes; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Window; + +/// +/// PowerPoint window management: get info, minimize, restore, maximize. +/// +[ServiceCategory("window")] +[McpTool("window", Title = "Window Operations", Destructive = false, Category = "window")] +public interface IWindowCommands +{ + /// + /// Get current window information (state, position, size). + /// + [ServiceAction("get-info")] + WindowInfoResult GetInfo(IPptBatch batch); + + /// + /// Minimize the PowerPoint window. + /// + [ServiceAction("minimize")] + OperationResult Minimize(IPptBatch batch); + + /// + /// Restore the PowerPoint window to normal size. + /// + [ServiceAction("restore")] + OperationResult Restore(IPptBatch batch); + + /// + /// Maximize the PowerPoint window. + /// + [ServiceAction("maximize")] + OperationResult Maximize(IPptBatch batch); + + /// + /// Set the zoom level of the active view (percentage). + /// + /// Batch context + /// Zoom percentage (e.g. 100 for 100%) + [ServiceAction("set-zoom")] + OperationResult SetZoom(IPptBatch batch, int zoomPercent); + + /// + /// Set the view type of the active window. + /// + /// Batch context + /// 1=Normal, 2=Outline, 3=SlideSorter, 4=NotesPage, 5=SlideMaster + [ServiceAction("set-view")] + OperationResult SetView(IPptBatch batch, int viewType); + + /// + /// Get the current view type of the active window. + /// + [ServiceAction("get-view")] + OperationResult GetView(IPptBatch batch); +} diff --git a/src/PptMcp.Core/Commands/Window/WindowCommands.cs b/src/PptMcp.Core/Commands/Window/WindowCommands.cs new file mode 100644 index 00000000..41acf3ae --- /dev/null +++ b/src/PptMcp.Core/Commands/Window/WindowCommands.cs @@ -0,0 +1,213 @@ +using PptMcp.ComInterop; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Models; + +namespace PptMcp.Core.Commands.Window; + +public class WindowCommands : IWindowCommands +{ + public WindowInfoResult GetInfo(IPptBatch batch) + { + return batch.Execute((ctx, ct) => + { + dynamic pres = (dynamic)ctx.Presentation; + dynamic app = pres.Application; + try + { + int state = Convert.ToInt32(app.WindowState); + return new WindowInfoResult + { + Success = true, + FilePath = ctx.PresentationPath, + WindowState = state, + WindowStateName = GetWindowStateName(state), + Left = (float)app.Left, + Top = (float)app.Top, + Width = (float)app.Width, + Height = (float)app.Height + }; + } + finally + { + ComUtilities.Release(ref app!); + } + }); + } + + public OperationResult Minimize(IPptBatch batch) + { + return batch.Execute((ctx, ct) => + { + dynamic app = ((dynamic)ctx.Presentation).Application; + try + { + // ppWindowMinimized = 2 + app.WindowState = 2; + return new OperationResult + { + Success = true, + Action = "minimize", + Message = "PowerPoint window minimized", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref app!); + } + }); + } + + public OperationResult Restore(IPptBatch batch) + { + return batch.Execute((ctx, ct) => + { + dynamic app = ((dynamic)ctx.Presentation).Application; + try + { + // ppWindowNormal = 1 + app.WindowState = 1; + return new OperationResult + { + Success = true, + Action = "restore", + Message = "PowerPoint window restored", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref app!); + } + }); + } + + public OperationResult Maximize(IPptBatch batch) + { + return batch.Execute((ctx, ct) => + { + dynamic app = ((dynamic)ctx.Presentation).Application; + try + { + // ppWindowMaximized = 3 + app.WindowState = 3; + return new OperationResult + { + Success = true, + Action = "maximize", + Message = "PowerPoint window maximized", + FilePath = ctx.PresentationPath + }; + } + finally + { + ComUtilities.Release(ref app!); + } + }); + } + + public OperationResult SetZoom(IPptBatch batch, int zoomPercent) + { + return batch.Execute((ctx, ct) => + { + dynamic app = ((dynamic)ctx.Presentation).Application; + dynamic? window = null; + try + { + window = app.ActiveWindow; + dynamic view = window.View; + try + { + view.Zoom = zoomPercent; + } + finally + { + ComUtilities.Release(ref view!); + } + + return new OperationResult + { + Success = true, + Action = "set-zoom", + Message = $"Set zoom to {zoomPercent}%", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (window != null) ComUtilities.Release(ref window!); + ComUtilities.Release(ref app!); + } + }); + } + + public OperationResult SetView(IPptBatch batch, int viewType) + { + return batch.Execute((ctx, ct) => + { + dynamic app = ((dynamic)ctx.Presentation).Application; + dynamic? window = null; + try + { + window = app.ActiveWindow; + window.ViewType = viewType; + return new OperationResult + { + Success = true, + Action = "set-view", + Message = $"Set view to {GetViewTypeName(viewType)}", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (window != null) ComUtilities.Release(ref window!); + ComUtilities.Release(ref app!); + } + }); + } + + public OperationResult GetView(IPptBatch batch) + { + return batch.Execute((ctx, ct) => + { + dynamic app = ((dynamic)ctx.Presentation).Application; + dynamic? window = null; + try + { + window = app.ActiveWindow; + int viewType = Convert.ToInt32(window.ViewType); + return new OperationResult + { + Success = true, + Action = "get-view", + Message = $"Current view: {GetViewTypeName(viewType)} ({viewType})", + FilePath = ctx.PresentationPath + }; + } + finally + { + if (window != null) ComUtilities.Release(ref window!); + ComUtilities.Release(ref app!); + } + }); + } + + private static string GetWindowStateName(int state) => state switch + { + 1 => "Normal", + 2 => "Minimized", + 3 => "Maximized", + _ => $"Unknown({state})" + }; + + private static string GetViewTypeName(int viewType) => viewType switch + { + 1 => "Normal", + 2 => "Outline", + 3 => "SlideSorter", + 4 => "NotesPage", + 5 => "SlideMaster", + _ => $"Unknown({viewType})" + }; +} diff --git a/src/PptMcp.Core/Models/ResultTypes.cs b/src/PptMcp.Core/Models/ResultTypes.cs new file mode 100644 index 00000000..3c5a0ad4 --- /dev/null +++ b/src/PptMcp.Core/Models/ResultTypes.cs @@ -0,0 +1,589 @@ +using System.Text.Json.Serialization; + +namespace PptMcp.Core.Models; + +/// +/// Base result type for all Core operations. +/// Exceptions propagate naturally — batch.Execute() re-throws them via TaskCompletionSource. +/// +public abstract class ResultBase +{ + public bool Success { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ErrorMessage { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? FilePath { get; set; } +} + +/// +/// Result for operations that don't return data (create, delete, etc.) +/// +public class OperationResult : ResultBase +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Action { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Message { get; set; } +} + +/// +/// Result for rename operations +/// +public class RenameResult : ResultBase +{ + public string ObjectType { get; set; } = string.Empty; + public string OldName { get; set; } = string.Empty; + public string NewName { get; set; } = string.Empty; +} + +// ── File / Session ──────────────────────────────────────── + +public class FileValidationInfo : ResultBase +{ + public bool Exists { get; set; } + public string FileName { get; set; } = string.Empty; + public long FileSizeBytes { get; set; } + public bool IsReadOnly { get; set; } + public bool IsMacroEnabled { get; set; } + public int SlideCount { get; set; } +} + +// ── Slide ───────────────────────────────────────────────── + +public class SlideListResult : ResultBase +{ + public List Slides { get; set; } = []; +} + +public class SlideInfo +{ + public int SlideIndex { get; set; } + public int SlideNumber { get; set; } + public string SlideId { get; set; } = string.Empty; + public string LayoutName { get; set; } = string.Empty; + public string MasterName { get; set; } = string.Empty; + public int ShapeCount { get; set; } + public bool HasNotes { get; set; } + public bool HasAnimations { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Name { get; set; } +} + +public class SlideDetailResult : ResultBase +{ + public SlideInfo? Slide { get; set; } + public List Shapes { get; set; } = []; +} + +// ── Shape ───────────────────────────────────────────────── + +public class ShapeListResult : ResultBase +{ + public int SlideIndex { get; set; } + public List Shapes { get; set; } = []; +} + +public class ShapeInfo +{ + public int ShapeId { get; set; } + public string Name { get; set; } = string.Empty; + public string ShapeType { get; set; } = string.Empty; + public float Left { get; set; } + public float Top { get; set; } + public float Width { get; set; } + public float Height { get; set; } + public int ZOrderPosition { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Text { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? AlternativeText { get; set; } + + public bool HasTextFrame { get; set; } + public bool HasTable { get; set; } + public bool HasChart { get; set; } + public bool IsGroup { get; set; } + public bool IsPlaceholder { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? PlaceholderType { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? GroupItems { get; set; } +} + +public class ShapeDetailResult : ResultBase +{ + public ShapeInfo? Shape { get; set; } +} + +// ── Text ────────────────────────────────────────────────── + +public class TextResult : ResultBase +{ + public int ShapeId { get; set; } + public string ShapeName { get; set; } = string.Empty; + public string Text { get; set; } = string.Empty; + public List Paragraphs { get; set; } = []; +} + +public class TextParagraphInfo +{ + public int Index { get; set; } + public string Text { get; set; } = string.Empty; + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Alignment { get; set; } + + public List Runs { get; set; } = []; +} + +public class TextRunInfo +{ + public string Text { get; set; } = string.Empty; + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? FontName { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public float? FontSize { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Bold { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Italic { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Color { get; set; } +} + +// ── Table (in shapes) ──────────────────────────────────── + +public class SlideTableResult : ResultBase +{ + public int ShapeId { get; set; } + public string ShapeName { get; set; } = string.Empty; + public int RowCount { get; set; } + public int ColumnCount { get; set; } + public List> Data { get; set; } = []; +} + +// ── Master / Layout ─────────────────────────────────────── + +public class MasterListResult : ResultBase +{ + public List Masters { get; set; } = []; +} + +public class MasterInfo +{ + public string Name { get; set; } = string.Empty; + public List Layouts { get; set; } = []; +} + +public class LayoutInfo +{ + public string Name { get; set; } = string.Empty; + public int Index { get; set; } +} + +// ── Notes ───────────────────────────────────────────────── + +public class NotesResult : ResultBase +{ + public int SlideIndex { get; set; } + public string Text { get; set; } = string.Empty; +} + +// ── Transition ──────────────────────────────────────────── + +public class TransitionResult : ResultBase +{ + public int SlideIndex { get; set; } + public string TransitionType { get; set; } = string.Empty; + public float Duration { get; set; } + public bool AdvanceOnClick { get; set; } + public float AdvanceAfterTime { get; set; } +} + +// ── Animation ───────────────────────────────────────────── + +public class AnimationListResult : ResultBase +{ + public int SlideIndex { get; set; } + public List Animations { get; set; } = []; +} + +public class AnimationInfo +{ + public int Index { get; set; } + public int ShapeId { get; set; } + public string ShapeName { get; set; } = string.Empty; + public string EffectType { get; set; } = string.Empty; + public string Timing { get; set; } = string.Empty; + public float Duration { get; set; } + public float Delay { get; set; } +} + +// ── Export ───────────────────────────────────────────────── + +public class ExportResult : ResultBase +{ + public string OutputPath { get; set; } = string.Empty; + public string Format { get; set; } = string.Empty; +} + +// ── Chart ────────────────────────────────────────────────── + +public class ChartInfoResult : ResultBase +{ + public int ShapeId { get; set; } + public string ShapeName { get; set; } = string.Empty; + public int ChartType { get; set; } + public string ChartTypeName { get; set; } = string.Empty; + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; set; } + + public bool HasLegend { get; set; } + public int SeriesCount { get; set; } +} + +// ── Design / Theme ──────────────────────────────────────── + +public class DesignListResult : ResultBase +{ + public List Designs { get; set; } = []; +} + +public class DesignInfo +{ + public int Index { get; set; } + public string Name { get; set; } = string.Empty; + public int LayoutCount { get; set; } +} + +public class ThemeColorResult : ResultBase +{ + public string DesignName { get; set; } = string.Empty; + public Dictionary Colors { get; set; } = []; +} + +// ── Slideshow ───────────────────────────────────────────── + +public class SlideshowInfoResult : ResultBase +{ + public bool IsRunning { get; set; } + public int CurrentSlide { get; set; } + public int TotalSlides { get; set; } +} + +// ── VBA ─────────────────────────────────────────────────── + +public class VbaModuleListResult : ResultBase +{ + public List Modules { get; set; } = []; +} + +public class VbaModuleInfo +{ + public string Name { get; set; } = string.Empty; + public int ModuleType { get; set; } + public string ModuleTypeName { get; set; } = string.Empty; + public int LineCount { get; set; } +} + +public class VbaModuleCodeResult : ResultBase +{ + public string ModuleName { get; set; } = string.Empty; + public string Code { get; set; } = string.Empty; + public int LineCount { get; set; } +} + +// ── Window ──────────────────────────────────────────────── + +public class WindowInfoResult : ResultBase +{ + public int WindowState { get; set; } + public string WindowStateName { get; set; } = string.Empty; + public float Left { get; set; } + public float Top { get; set; } + public float Width { get; set; } + public float Height { get; set; } +} + +// ── Hyperlink ───────────────────────────────────────────── + +public class HyperlinkResult : ResultBase +{ + public int SlideIndex { get; set; } + public string ShapeName { get; set; } = string.Empty; + public bool HasHyperlink { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Address { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? SubAddress { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ScreenTip { get; set; } +} + +public class HyperlinkListResult : ResultBase +{ + public List Hyperlinks { get; set; } = []; +} + +public class HyperlinkInfo +{ + public int Index { get; set; } + public string Address { get; set; } = string.Empty; + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? SubAddress { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ScreenTip { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int SlideIndex { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ShapeName { get; set; } +} + +// ── Section ─────────────────────────────────────────────── + +public class SectionListResult : ResultBase +{ + public List Sections { get; set; } = []; +} + +public class SectionInfo +{ + public int Index { get; set; } + public string Name { get; set; } = string.Empty; + public int FirstSlideIndex { get; set; } + public int SlideCount { get; set; } +} + +// ── Document Properties ─────────────────────────────────── + +public class DocumentPropertyResult : ResultBase +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Subject { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Author { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Keywords { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Comments { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Company { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Category { get; set; } +} + +// ── Media ───────────────────────────────────────────────── + +public class MediaInfoResult : ResultBase +{ + public int SlideIndex { get; set; } + public string ShapeName { get; set; } = string.Empty; + public string MediaType { get; set; } = string.Empty; + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? SourceFile { get; set; } + + public float Left { get; set; } + public float Top { get; set; } + public float Width { get; set; } + public float Height { get; set; } +} + +// ── Comment ────────────────────────────────────────────── + +public class CommentListResult : ResultBase +{ + public List Comments { get; set; } = []; +} + +public class CommentInfo +{ + public int SlideIndex { get; set; } + public int CommentIndex { get; set; } + public string Author { get; set; } = string.Empty; + public string Text { get; set; } = string.Empty; + public float Left { get; set; } + public float Top { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DateTime { get; set; } +} + +// ── Placeholder ────────────────────────────────────────── + +public class PlaceholderListResult : ResultBase +{ + public int SlideIndex { get; set; } + public List Placeholders { get; set; } = []; +} + +public class PlaceholderInfo +{ + public int Index { get; set; } + public string Name { get; set; } = string.Empty; + public int PlaceholderType { get; set; } + public string PlaceholderTypeName { get; set; } = string.Empty; + public bool HasTextFrame { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Text { get; set; } +} + +// ── Background ─────────────────────────────────────────── + +public class BackgroundResult : ResultBase +{ + public int SlideIndex { get; set; } + public bool FollowMasterBackground { get; set; } + public string FillType { get; set; } = string.Empty; + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Color { get; set; } +} + +// ── Header/Footer ──────────────────────────────────────── + +public class HeaderFooterResult : ResultBase +{ + public bool ShowFooter { get; set; } + public bool ShowSlideNumber { get; set; } + public bool ShowDate { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? FooterText { get; set; } +} + +// ── SmartArt ───────────────────────────────────────────── + +public class SmartArtInfoResult : ResultBase +{ + public int SlideIndex { get; set; } + public string ShapeName { get; set; } = string.Empty; + public string LayoutName { get; set; } = string.Empty; + public List Nodes { get; set; } = []; +} + +public class SmartArtNodeInfo +{ + public int Index { get; set; } + public string Text { get; set; } = string.Empty; + public int Level { get; set; } +} + +// ── Custom Show ────────────────────────────────────────── + +public class CustomShowListResult : ResultBase +{ + public List Shows { get; set; } = []; +} + +public class CustomShowInfo +{ + public int Index { get; set; } + public string Name { get; set; } = string.Empty; + public int SlideCount { get; set; } + public List SlideIds { get; set; } = []; +} + +// ── Page Setup ─────────────────────────────────────────── + +public class PageSetupResult : ResultBase +{ + public float SlideWidth { get; set; } + public float SlideHeight { get; set; } + public int SlideOrientation { get; set; } + public int NotesOrientation { get; set; } +} + +// ── Tags ───────────────────────────────────────────────── + +public class TagListResult : ResultBase +{ + public int SlideIndex { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ShapeName { get; set; } + + public List Tags { get; set; } = []; +} + +public class TagInfo +{ + public string Name { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; +} + +// ── Color Scheme ───────────────────────────────────────── + +public class ColorSchemeListResult : ResultBase +{ + public List ColorSchemes { get; set; } = []; +} + +public class ColorSchemeInfo +{ + public int Index { get; set; } + public Dictionary Colors { get; set; } = []; +} + +// ── Accessibility ──────────────────────────────────────── + +public class AccessibilityAuditResult : OperationResult +{ + public int TotalSlides { get; set; } + public int IssueCount { get; set; } + public List Issues { get; set; } = []; +} + +public class AccessibilityIssue +{ + public int SlideIndex { get; set; } + public string IssueType { get; set; } = string.Empty; + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ShapeName { get; set; } + + public string Description { get; set; } = string.Empty; +} + +public class ReadingOrderResult : ResultBase +{ + public int SlideIndex { get; set; } + public List Shapes { get; set; } = []; +} + +public class ReadingOrderEntry +{ + public int Position { get; set; } + public string ShapeName { get; set; } = string.Empty; + public string ShapeType { get; set; } = string.Empty; + public int ZOrderPosition { get; set; } +} diff --git a/src/ExcelMcp.Core/ExcelMcp.Core.csproj b/src/PptMcp.Core/PptMcp.Core.csproj similarity index 65% rename from src/ExcelMcp.Core/ExcelMcp.Core.csproj rename to src/PptMcp.Core/PptMcp.Core.csproj index 2e0a8f93..d6457b13 100644 --- a/src/ExcelMcp.Core/ExcelMcp.Core.csproj +++ b/src/PptMcp.Core/PptMcp.Core.csproj @@ -1,13 +1,13 @@ - + - net10.0 + net9.0 enable enable - Sbroenne.ExcelMcp.Core - Sbroenne.ExcelMcp.Core + PptMcp.Core + PptMcp.Core 1.0.0 @@ -15,20 +15,21 @@ 1.0.0.0 - Sbroenne.ExcelMcp.Core - Sbroenne ExcelMcp Core Library - Core library for Excel automation operations via COM interop by Sbroenne. Provides high-level commands for Power Query, VBA, Data Model (Power Pivot), worksheets, ranges, tables, parameters, and connections. Shared by ExcelMcp.McpServer and ExcelMcp.CLI. - excel;automation;com;powerquery;vba;datamodel;dax;pivot;core;mcp;sbroenne + PptMcp.Core + PptMcp Core Library + Core library for PowerPoint automation operations via COM interop. Provides high-level commands for slides, shapes, text, charts, and more. Shared by PptMcp.McpServer and PptMcp.CLI. + powerpoint;automation;com;slides;shapes;core;mcp README.md - See https://github.com/sbroenne/mcp-server-excel/releases for release notes + See https://github.com/trsdn/mcp-server-ppt/releases for release notes false true - + true bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml + $(NoWarn);CS1591 @@ -54,7 +55,7 @@ - + @@ -64,9 +65,9 @@ - + - @@ -78,11 +79,8 @@ - - + + true NU1701 diff --git a/src/ExcelMcp.Core/Utilities/ParameterTransforms.cs b/src/PptMcp.Core/Utilities/ParameterTransforms.cs similarity index 72% rename from src/ExcelMcp.Core/Utilities/ParameterTransforms.cs rename to src/PptMcp.Core/Utilities/ParameterTransforms.cs index e550d5f9..ef010e6e 100644 --- a/src/ExcelMcp.Core/Utilities/ParameterTransforms.cs +++ b/src/PptMcp.Core/Utilities/ParameterTransforms.cs @@ -1,8 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; -using Sbroenne.ExcelMcp.Core.Commands.Range; -namespace Sbroenne.ExcelMcp.Core.Utilities; +namespace PptMcp.Core.Utilities; /// /// Shared parameter transformation utilities used by MCP, CLI, and generated code. @@ -184,74 +183,6 @@ public static class ParameterTransforms return csvRows; } - /// - /// Resolves string formulas from either an inline 2D array or a JSON file path. - /// - /// Inline 2D array of formulas (may be null if file is provided) - /// Path to JSON file containing formulas - /// Parameter name for error messages - /// Resolved 2D array of formulas - public static List> ResolveFormulasOrFile(List>? formulas, string? formulasFile, string parameterName = "formulas") - { - if (formulas != null && formulas.Count > 0) - return formulas; - - if (string.IsNullOrWhiteSpace(formulasFile)) - throw new ArgumentException($"Either {parameterName} or {parameterName}File must be provided for set-formulas action", parameterName); - - if (!File.Exists(formulasFile)) - throw new FileNotFoundException($"Formulas file not found: {formulasFile}", formulasFile); - - var content = File.ReadAllText(formulasFile); - try - { - var parsed = JsonSerializer.Deserialize>>(content, s_jsonOptions); - return parsed ?? throw new ArgumentException($"JSON file '{formulasFile}' deserialized to null"); - } - catch (JsonException ex) - { - throw new ArgumentException( - $"Invalid JSON in formulas file '{formulasFile}': {ex.Message}. Expected 2D array: [[\"=A1+B1\"],[\"=SUM(A:A)\"]]", - parameterName); - } - } - - // === Options Object Construction === - - /// - /// Builds a FindOptions object from individual boolean parameters with defaults. - /// - public static FindOptions BuildFindOptions( - bool? matchCase = null, - bool? matchEntireCell = null, - bool? searchFormulas = null, - bool? searchValues = null) - { - return new FindOptions - { - MatchCase = matchCase ?? false, - MatchEntireCell = matchEntireCell ?? false, - SearchFormulas = searchFormulas ?? true, - SearchValues = searchValues ?? true - }; - } - - /// - /// Builds a ReplaceOptions object from individual boolean parameters with defaults. - /// - public static ReplaceOptions BuildReplaceOptions( - bool? matchCase = null, - bool? matchEntireCell = null, - bool? replaceAll = null) - { - return new ReplaceOptions - { - MatchCase = matchCase ?? false, - MatchEntireCell = matchEntireCell ?? false, - ReplaceAll = replaceAll ?? true - }; - } - /// /// Resolves a value that can come from either a direct string or a file path. /// If filePath is provided and exists, reads file content. Otherwise returns directValue. @@ -272,23 +203,6 @@ public static ReplaceOptions BuildReplaceOptions( return directValue; } - /// - /// Parses a string load destination to the PowerQueryLoadMode enum. - /// - /// String value: "worksheet", "data-model", "both", "connection-only" - /// The corresponding PowerQueryLoadMode enum value - public static Models.PowerQueryLoadMode ParseLoadMode(string? loadDestination) - { - return loadDestination?.ToLowerInvariant() switch - { - "worksheet" or "table" => Models.PowerQueryLoadMode.LoadToTable, - "data-model" or "datamodel" => Models.PowerQueryLoadMode.LoadToDataModel, - "both" => Models.PowerQueryLoadMode.LoadToBoth, - "connection-only" or "connectiononly" => Models.PowerQueryLoadMode.ConnectionOnly, - _ => Models.PowerQueryLoadMode.LoadToTable - }; - } - /// /// Validates that a required parameter is not null or empty. /// diff --git a/src/ExcelMcp.Generators.Cli/CliSettingsGenerator.cs b/src/PptMcp.Generators.Cli/CliSettingsGenerator.cs similarity index 86% rename from src/ExcelMcp.Generators.Cli/CliSettingsGenerator.cs rename to src/PptMcp.Generators.Cli/CliSettingsGenerator.cs index a72a86b7..225c5144 100644 --- a/src/ExcelMcp.Generators.Cli/CliSettingsGenerator.cs +++ b/src/PptMcp.Generators.Cli/CliSettingsGenerator.cs @@ -2,7 +2,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; -namespace Sbroenne.ExcelMcp.Generators.Cli; +namespace PptMcp.Generators.Cli; /// /// Generates CLI command classes and registration. @@ -57,11 +57,21 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // Use string-based matching (robust across compilation boundaries) var attr = type.GetAttributes().FirstOrDefault(a => a.AttributeClass?.Name == "ServiceCategoryAttribute" && - a.AttributeClass?.ContainingNamespace?.ToDisplayString() == "Sbroenne.ExcelMcp.Core.Attributes"); - if (attr == null || attr.ConstructorArguments.Length < 2) + a.AttributeClass?.ContainingNamespace?.ToDisplayString() == "PptMcp.Core.Attributes"); + if (attr == null || attr.ConstructorArguments.Length < 1) continue; - var categoryPascal = attr.ConstructorArguments[1].Value?.ToString() ?? ""; + var category = attr.ConstructorArguments[0].Value?.ToString() ?? ""; + // PascalName from second constructor arg, or derive from category + var categoryPascal = attr.ConstructorArguments.Length > 1 + ? attr.ConstructorArguments[1].Value?.ToString() ?? "" + : ""; + + if (string.IsNullOrEmpty(categoryPascal)) + { + // Derive PascalCase from category (e.g., "slide" → "Slide", "pivottable" → "Pivottable") + categoryPascal = char.ToUpperInvariant(category[0]) + category.Substring(1); + } // Get McpTool name for deriving CLI command name var mcpToolAttr = type.GetAttributes().FirstOrDefault(a => @@ -96,10 +106,10 @@ private static string GenerateCommandClass(string registryName, bool requiresSes sb.AppendLine("// "); sb.AppendLine("#nullable enable"); sb.AppendLine(); - sb.AppendLine("using Sbroenne.ExcelMcp.CLI.Infrastructure;"); - sb.AppendLine("using Sbroenne.ExcelMcp.Generated;"); + sb.AppendLine("using PptMcp.CLI.Infrastructure;"); + sb.AppendLine("using PptMcp.Generated;"); sb.AppendLine(); - sb.AppendLine("namespace Sbroenne.ExcelMcp.CLI.Generated;"); + sb.AppendLine("namespace PptMcp.CLI.Generated;"); sb.AppendLine(); sb.AppendLine($"internal sealed class {registryName}Command : ServiceCommandBase"); sb.AppendLine("{"); @@ -124,11 +134,11 @@ private static string GenerateRegistration(List<(string CliName, string Registry sb.AppendLine("// "); sb.AppendLine("#nullable enable"); sb.AppendLine(); - sb.AppendLine("using Sbroenne.ExcelMcp.Generated;"); + sb.AppendLine("using PptMcp.Generated;"); sb.AppendLine("using Spectre.Console;"); sb.AppendLine("using Spectre.Console.Cli;"); sb.AppendLine(); - sb.AppendLine("namespace Sbroenne.ExcelMcp.CLI.Generated;"); + sb.AppendLine("namespace PptMcp.CLI.Generated;"); sb.AppendLine(); sb.AppendLine("internal static class CliCommandRegistration"); sb.AppendLine("{"); diff --git a/src/ExcelMcp.Generators.Cli/ExcelMcp.Generators.Cli.csproj b/src/PptMcp.Generators.Cli/PptMcp.Generators.Cli.csproj similarity index 86% rename from src/ExcelMcp.Generators.Cli/ExcelMcp.Generators.Cli.csproj rename to src/PptMcp.Generators.Cli/PptMcp.Generators.Cli.csproj index 3e293742..f8ffc1a8 100644 --- a/src/ExcelMcp.Generators.Cli/ExcelMcp.Generators.Cli.csproj +++ b/src/PptMcp.Generators.Cli/PptMcp.Generators.Cli.csproj @@ -7,7 +7,7 @@ enable true true - Sbroenne.ExcelMcp.Generators.Cli + PptMcp.Generators.Cli false @@ -25,7 +25,7 @@ - + diff --git a/src/ExcelMcp.Generators.Mcp/McpToolGenerator.cs b/src/PptMcp.Generators.Mcp/McpToolGenerator.cs similarity index 92% rename from src/ExcelMcp.Generators.Mcp/McpToolGenerator.cs rename to src/PptMcp.Generators.Mcp/McpToolGenerator.cs index 37fa9747..e5f25aee 100644 --- a/src/ExcelMcp.Generators.Mcp/McpToolGenerator.cs +++ b/src/PptMcp.Generators.Mcp/McpToolGenerator.cs @@ -1,9 +1,9 @@ using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; -using Sbroenne.ExcelMcp.Generators.Common; +using PptMcp.Generators.Common; -namespace Sbroenne.ExcelMcp.Generators.Mcp; +namespace PptMcp.Generators.Mcp; /// /// Generates MCP Server tool classes from Core [ServiceCategory] interfaces. @@ -86,15 +86,15 @@ private static string GenerateToolClass(ServiceInfo info) sb.AppendLine(); sb.AppendLine("using System.ComponentModel;"); sb.AppendLine("using ModelContextProtocol.Server;"); - sb.AppendLine("using Sbroenne.ExcelMcp.Generated;"); + sb.AppendLine("using PptMcp.Generated;"); if (hasProgress) { sb.AppendLine("using ModelContextProtocol;"); - sb.AppendLine("using Sbroenne.ExcelMcp.ComInterop;"); - sb.AppendLine("using Sbroenne.ExcelMcp.McpServer.Progress;"); + sb.AppendLine("using PptMcp.ComInterop;"); + sb.AppendLine("using PptMcp.McpServer.Progress;"); } sb.AppendLine(); - sb.AppendLine("namespace Sbroenne.ExcelMcp.McpServer.Tools;"); + sb.AppendLine("namespace PptMcp.McpServer.Tools;"); sb.AppendLine(); // Class XML doc @@ -155,7 +155,7 @@ private static void GenerateToolMethod(StringBuilder sb, ServiceInfo info, bool } // Attributes - var title = info.McpToolTitle ?? $"Excel {info.CategoryPascal} Operations"; + var title = info.McpToolTitle ?? $"PowerPoint {info.CategoryPascal} Operations"; var destructive = info.McpToolDestructive ? "true" : "false"; sb.AppendLine($" [McpServerTool(Name = \"{info.McpToolName}\", Title = \"{title}\", Destructive = {destructive})]"); @@ -163,13 +163,18 @@ private static void GenerateToolMethod(StringBuilder sb, ServiceInfo info, bool sb.AppendLine($" [McpMeta(\"category\", \"{category}\")]"); sb.AppendLine($" [McpMeta(\"requiresSession\", {(!info.NoSession).ToString().ToLower()})]"); - // Method-level [Description] from [McpTool(Description = "...")] attribute. + // Method-level [Description] from [McpTool(Description = "...")] attribute or fallback to title. // Source generators can't read XML docs from metadata references. if (!string.IsNullOrEmpty(info.McpToolDescription)) { var methodDesc = EscapeStringLiteral(info.McpToolDescription); sb.AppendLine($" [Description(\"{methodDesc}\")]"); } + else + { + // Fallback: use the tool title as description so MCP SDK exposes it + sb.AppendLine($" [Description(\"{EscapeStringLiteral(title)}\")]"); + } // Method signature — non-partial because MCP SDK's XmlToDescriptionGenerator // cannot see our generator output to create a matching defining declaration. @@ -231,7 +236,7 @@ private static void GenerateMethodBody(StringBuilder sb, ServiceInfo info, List< // Null check: action is nullable to prevent SDK-level exception when param is missing. // Return a helpful error so the LLM can retry with the correct action. sb.AppendLine($" if (action == null)"); - sb.AppendLine($" return ExcelToolsBase.MissingActionError(\"{toolName}\");"); + sb.AppendLine($" return PptToolsBase.MissingActionError(\"{toolName}\");"); sb.AppendLine(); var hasPreProcessing = false; foreach (var p in mcpParams) @@ -258,7 +263,7 @@ private static void GenerateMethodBody(StringBuilder sb, ServiceInfo info, List< var indent = hasProgress ? " " : " "; - sb.AppendLine($"{indent}return ExcelToolsBase.ExecuteToolAction("); + sb.AppendLine($"{indent}return PptToolsBase.ExecuteToolAction("); sb.AppendLine($"{indent} \"{toolName}\","); sb.AppendLine($"{indent} ServiceRegistry.{registryName}.ToActionString(action.Value),"); sb.AppendLine($"{indent} () => ServiceRegistry.{registryName}.RouteAction("); @@ -273,7 +278,14 @@ private static void GenerateMethodBody(StringBuilder sb, ServiceInfo info, List< sb.AppendLine($"{indent} \"\","); } - sb.AppendLine($"{indent} ExcelToolsBase.ForwardToServiceFunc,"); + if (mcpParams.Count == 0) + { + sb.AppendLine($"{indent} PptToolsBase.ForwardToServiceFunc"); + } + else + { + sb.AppendLine($"{indent} PptToolsBase.ForwardToServiceFunc,"); + } // Named arguments to RouteAction for (int i = 0; i < mcpParams.Count; i++) @@ -370,7 +382,7 @@ private static List BuildMcpParameters(ServiceInfo info, Listenable true true - Sbroenne.ExcelMcp.Generators.Mcp + PptMcp.Generators.Mcp false @@ -25,7 +25,7 @@ - + diff --git a/src/ExcelMcp.Generators.Shared/Models.cs b/src/PptMcp.Generators.Shared/Models.cs similarity index 96% rename from src/ExcelMcp.Generators.Shared/Models.cs rename to src/PptMcp.Generators.Shared/Models.cs index 8ecab922..ed5fb3d1 100644 --- a/src/ExcelMcp.Generators.Shared/Models.cs +++ b/src/PptMcp.Generators.Shared/Models.cs @@ -1,4 +1,4 @@ -namespace Sbroenne.ExcelMcp.Generators.Common; +namespace PptMcp.Generators.Common; /// /// Extracted service information from an interface marked with [ServiceCategory]. @@ -12,7 +12,7 @@ public sealed class ServiceInfo public string? XmlDocSummary { get; } public List Methods { get; } - /// Human-readable title for the MCP tool (e.g., "Excel Power Query Operations"). + /// Human-readable title for the MCP tool (e.g., "Slide Operations"). public string? McpToolTitle { get; } /// Whether the tool is destructive (modifies data). Default: true. @@ -56,7 +56,7 @@ public sealed class MethodInfo public string McpTool { get; } public List Parameters { get; } public string? XmlDocSummary { get; } - /// Whether the original interface method has an IExcelBatch parameter. + /// Whether the original interface method has an IPptBatch parameter. public bool HasBatchParameter { get; } /// Whether the original interface method has an IProgress<T> parameter. diff --git a/src/ExcelMcp.Generators.Shared/ExcelMcp.Generators.Shared.csproj b/src/PptMcp.Generators.Shared/PptMcp.Generators.Shared.csproj similarity index 90% rename from src/ExcelMcp.Generators.Shared/ExcelMcp.Generators.Shared.csproj rename to src/PptMcp.Generators.Shared/PptMcp.Generators.Shared.csproj index 529d147d..b4363f6e 100644 --- a/src/ExcelMcp.Generators.Shared/ExcelMcp.Generators.Shared.csproj +++ b/src/PptMcp.Generators.Shared/PptMcp.Generators.Shared.csproj @@ -5,7 +5,7 @@ latest enable enable - Sbroenne.ExcelMcp.Generators.Shared + PptMcp.Generators.Shared false diff --git a/src/ExcelMcp.Generators.Shared/ServiceInfoExtractor.cs b/src/PptMcp.Generators.Shared/ServiceInfoExtractor.cs similarity index 98% rename from src/ExcelMcp.Generators.Shared/ServiceInfoExtractor.cs rename to src/PptMcp.Generators.Shared/ServiceInfoExtractor.cs index 0714f32e..6e9f66cc 100644 --- a/src/ExcelMcp.Generators.Shared/ServiceInfoExtractor.cs +++ b/src/PptMcp.Generators.Shared/ServiceInfoExtractor.cs @@ -4,7 +4,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; -namespace Sbroenne.ExcelMcp.Generators.Common; +namespace PptMcp.Generators.Common; /// /// Extracts ServiceInfo from interfaces marked with [ServiceCategory]. @@ -88,10 +88,10 @@ public static class ServiceInfoExtractor var methodMcpTool = GetMethodMcpTool(method) ?? mcpTool; var xmlDoc = ExtractXmlDocumentation(method); - var hasBatchParameter = method.Parameters.Any(p => p.Type.Name == "IExcelBatch"); + var hasBatchParameter = method.Parameters.Any(p => p.Type.Name == "IPptBatch"); var hasProgressParameter = method.Parameters.Any(p => p.Type.Name == "IProgress"); var parameters = method.Parameters - .Where(p => p.Type.Name != "IExcelBatch" && p.Type.Name != "IProgress") // Skip batch and progress parameters + .Where(p => p.Type.Name != "IPptBatch" && p.Type.Name != "IProgress") // Skip batch and progress parameters .Select(p => ExtractParameterInfo(p, xmlDoc)) .ToList(); diff --git a/src/ExcelMcp.Generators.Shared/StringHelper.cs b/src/PptMcp.Generators.Shared/StringHelper.cs similarity index 96% rename from src/ExcelMcp.Generators.Shared/StringHelper.cs rename to src/PptMcp.Generators.Shared/StringHelper.cs index 8307851e..7a104e0c 100644 --- a/src/ExcelMcp.Generators.Shared/StringHelper.cs +++ b/src/PptMcp.Generators.Shared/StringHelper.cs @@ -1,7 +1,7 @@ using System.Text; using Microsoft.CodeAnalysis; -namespace Sbroenne.ExcelMcp.Generators.Common; +namespace PptMcp.Generators.Common; /// /// String manipulation utilities shared between generators. @@ -75,14 +75,12 @@ public static string GetParameterDescription(string paramName) "queryName" => "Query name", "mCode" => "M code formula", "mCodeFile" => "Path to file containing M code", - "loadDestination" => "Load destination: worksheet, data-model, both, connection-only", - "targetSheet" => "Target worksheet name", "targetCellAddress" => "Target cell address (e.g., A1)", "oldName" => "Current name (for rename)", "newName" => "New name (for rename)", "timeout" => "Timeout duration", "refresh" => "Whether to refresh after update", - "sheetName" => "Worksheet name", + "sheetName" => "Slide name", "tableName" => "Table name", "connectionName" => "Connection name", "chartName" => "Chart name", diff --git a/src/ExcelMcp.Generators.Shared/SyntaxHelper.cs b/src/PptMcp.Generators.Shared/SyntaxHelper.cs similarity index 96% rename from src/ExcelMcp.Generators.Shared/SyntaxHelper.cs rename to src/PptMcp.Generators.Shared/SyntaxHelper.cs index 24baaca0..0bb22bdc 100644 --- a/src/ExcelMcp.Generators.Shared/SyntaxHelper.cs +++ b/src/PptMcp.Generators.Shared/SyntaxHelper.cs @@ -1,7 +1,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; -namespace Sbroenne.ExcelMcp.Generators.Common; +namespace PptMcp.Generators.Common; /// /// Helper methods for syntax analysis shared between generators. diff --git a/src/ExcelMcp.Generators/ExcelMcp.Generators.csproj b/src/PptMcp.Generators/PptMcp.Generators.csproj similarity index 87% rename from src/ExcelMcp.Generators/ExcelMcp.Generators.csproj rename to src/PptMcp.Generators/PptMcp.Generators.csproj index 8397a60e..8a9b8bd4 100644 --- a/src/ExcelMcp.Generators/ExcelMcp.Generators.csproj +++ b/src/PptMcp.Generators/PptMcp.Generators.csproj @@ -7,7 +7,7 @@ enable true true - Sbroenne.ExcelMcp.Generators + PptMcp.Generators false @@ -25,7 +25,7 @@ - + diff --git a/src/ExcelMcp.Generators/ServiceRegistryGenerator.cs b/src/PptMcp.Generators/ServiceRegistryGenerator.cs similarity index 92% rename from src/ExcelMcp.Generators/ServiceRegistryGenerator.cs rename to src/PptMcp.Generators/ServiceRegistryGenerator.cs index be315387..18f206bb 100644 --- a/src/ExcelMcp.Generators/ServiceRegistryGenerator.cs +++ b/src/PptMcp.Generators/ServiceRegistryGenerator.cs @@ -3,9 +3,9 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; -using Sbroenne.ExcelMcp.Generators.Common; +using PptMcp.Generators.Common; -namespace Sbroenne.ExcelMcp.Generators; +namespace PptMcp.Generators; /// /// Generates ServiceRegistry constants and DTOs from Core command interfaces @@ -108,7 +108,7 @@ private static string GenerateServiceRegistry(ServiceInfo info) sb.AppendLine("#nullable enable"); sb.AppendLine("#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member"); sb.AppendLine(); - sb.AppendLine("namespace Sbroenne.ExcelMcp.Generated;"); + sb.AppendLine("namespace PptMcp.Generated;"); sb.AppendLine(); // Generate the Action enum with kebab-case JSON names @@ -272,15 +272,21 @@ private static string GenerateServiceRegistry(ServiceInfo info) sb.AppendLine($" public static string RouteAction("); sb.AppendLine($" {info.CategoryPascal}Action action,"); sb.AppendLine($" string sessionId,"); - sb.AppendLine($" System.Func forwardToService,"); - // Collect all unique exposed parameters across all methods var allExposedParams = GetAllExposedParameters(info); - for (int i = 0; i < allExposedParams.Count; i++) + if (allExposedParams.Count == 0) { - var p = allExposedParams[i]; - var comma = i < allExposedParams.Count - 1 ? "," : ")"; - sb.AppendLine($" {p.TypeName} {p.Name} = {p.DefaultValue ?? "null"}{comma}"); + sb.AppendLine($" System.Func forwardToService)"); + } + else + { + sb.AppendLine($" System.Func forwardToService,"); + for (int i = 0; i < allExposedParams.Count; i++) + { + var p = allExposedParams[i]; + var comma = i < allExposedParams.Count - 1 ? "," : ")"; + sb.AppendLine($" {p.TypeName} {p.Name} = {p.DefaultValue ?? "null"}{comma}"); + } } sb.AppendLine(" {"); @@ -316,15 +322,22 @@ private static void GenerateCliRouteMethod(StringBuilder sb, ServiceInfo info) sb.AppendLine(" /// Returns (command, args) tuple for sending to service."); sb.AppendLine(" /// "); sb.AppendLine($" public static (string Command, object? Args) RouteCliArgs("); - sb.AppendLine($" string action,"); // Collect all unique exposed parameters var allExposedParams = GetAllExposedParameters(info); - for (int i = 0; i < allExposedParams.Count; i++) + if (allExposedParams.Count == 0) + { + sb.AppendLine($" string action)"); + } + else { - var p = allExposedParams[i]; - var comma = i < allExposedParams.Count - 1 ? "," : ")"; - sb.AppendLine($" {p.TypeName} {p.Name} = {p.DefaultValue ?? "null"}{comma}"); + sb.AppendLine($" string action,"); + for (int i = 0; i < allExposedParams.Count; i++) + { + var p = allExposedParams[i]; + var comma = i < allExposedParams.Count - 1 ? "," : ")"; + sb.AppendLine($" {p.TypeName} {p.Name} = {p.DefaultValue ?? "null"}{comma}"); + } } sb.AppendLine(" {"); @@ -349,17 +362,17 @@ private static void GenerateCliRouteMethod(StringBuilder sb, ServiceInfo info) foreach (var p in method.Parameters.Where(p => !p.IsFileOrValue && (p.IsRequired || (!p.HasDefault && !p.TypeName.EndsWith("?"))) && StringHelper.IsStringType(p.TypeName))) { var paramName = p.IsFromString && p.IsEnum ? (p.ExposedName ?? p.Name) : p.Name; - sb.AppendLine($" Sbroenne.ExcelMcp.Core.Utilities.ParameterTransforms.RequireNotEmpty({paramName}, \"{paramName}\", \"{method.ActionName}\");"); + sb.AppendLine($" PptMcp.Core.Utilities.ParameterTransforms.RequireNotEmpty({paramName}, \"{paramName}\", \"{method.ActionName}\");"); } // Resolve FileOrValue parameters (read file content if file path provided) foreach (var p in method.Parameters.Where(p => p.IsFileOrValue)) { - sb.AppendLine($" var resolved{StringHelper.ToPascalCase(p.Name)} = Sbroenne.ExcelMcp.Core.Utilities.ParameterTransforms.ResolveFileOrValue({p.Name}, {p.Name}{p.FileSuffix});"); + sb.AppendLine($" var resolved{StringHelper.ToPascalCase(p.Name)} = PptMcp.Core.Utilities.ParameterTransforms.ResolveFileOrValue({p.Name}, {p.Name}{p.FileSuffix});"); // Validate required FileOrValue parameters AFTER resolution if (p.IsRequired || (!p.HasDefault && !p.TypeName.EndsWith("?"))) { - sb.AppendLine($" Sbroenne.ExcelMcp.Core.Utilities.ParameterTransforms.RequireNotEmpty(resolved{StringHelper.ToPascalCase(p.Name)}, \"{p.Name}\", \"{method.ActionName}\");"); + sb.AppendLine($" PptMcp.Core.Utilities.ParameterTransforms.RequireNotEmpty(resolved{StringHelper.ToPascalCase(p.Name)}, \"{p.Name}\", \"{method.ActionName}\");"); } } @@ -391,34 +404,35 @@ private static void GenerateRouteFromSettings(StringBuilder sb, ServiceInfo info sb.AppendLine(" /// "); sb.AppendLine(" public static (string Command, object? Args) RouteFromSettings(string action, CliSettings settings)"); sb.AppendLine(" {"); - sb.AppendLine(" return RouteCliArgs("); - sb.AppendLine(" action,"); - for (int i = 0; i < allExposedParams.Count; i++) + if (allExposedParams.Count == 0) + { + sb.AppendLine(" return RouteCliArgs(action);"); + } + else { - var p = allExposedParams[i]; - var comma = i < allExposedParams.Count - 1 ? "," : ");"; + sb.AppendLine(" return RouteCliArgs("); + sb.AppendLine(" action,"); - if (IsNestedCollectionType(p.TypeName)) + for (int i = 0; i < allExposedParams.Count; i++) { - // CLI Settings stores nested collections as string (JSON). - // Deserialize back to the original type for RouteCliArgs. - // Uses DeserializeNestedCollection which auto-wraps 1D arrays to 2D - // (e.g., ["a","b"] → [["a","b"]]) for better LLM compatibility. - var nonNullableType = p.TypeName.TrimEnd('?'); - sb.AppendLine($" {p.Name}: !string.IsNullOrWhiteSpace(settings.{StringHelper.ToPascalCase(p.Name)}) ? ServiceRegistry.DeserializeNestedCollection<{nonNullableType}>(settings.{StringHelper.ToPascalCase(p.Name)}) : null{comma}"); - } - else if (IsSimpleListType(p.TypeName)) - { - // CLI Settings stores simple lists as string (JSON) for LLM compatibility. - // LLMs pass JSON arrays (e.g., '["a","b"]') instead of repeated CLI flags. - // Deserialize back to the original type for RouteCliArgs. - var nonNullableType = p.TypeName.TrimEnd('?'); - sb.AppendLine($" {p.Name}: !string.IsNullOrWhiteSpace(settings.{StringHelper.ToPascalCase(p.Name)}) ? ServiceRegistry.DeserializeList<{nonNullableType}>(settings.{StringHelper.ToPascalCase(p.Name)}) : null{comma}"); - } - else - { - sb.AppendLine($" {p.Name}: settings.{StringHelper.ToPascalCase(p.Name)}{comma}"); + var p = allExposedParams[i]; + var comma = i < allExposedParams.Count - 1 ? "," : ");"; + + if (IsNestedCollectionType(p.TypeName)) + { + var nonNullableType = p.TypeName.TrimEnd('?'); + sb.AppendLine($" {p.Name}: !string.IsNullOrWhiteSpace(settings.{StringHelper.ToPascalCase(p.Name)}) ? ServiceRegistry.DeserializeNestedCollection<{nonNullableType}>(settings.{StringHelper.ToPascalCase(p.Name)}) : null{comma}"); + } + else if (IsSimpleListType(p.TypeName)) + { + var nonNullableType = p.TypeName.TrimEnd('?'); + sb.AppendLine($" {p.Name}: !string.IsNullOrWhiteSpace(settings.{StringHelper.ToPascalCase(p.Name)}) ? ServiceRegistry.DeserializeList<{nonNullableType}>(settings.{StringHelper.ToPascalCase(p.Name)}) : null{comma}"); + } + else + { + sb.AppendLine($" {p.Name}: settings.{StringHelper.ToPascalCase(p.Name)}{comma}"); + } } } @@ -582,7 +596,7 @@ private static void GenerateForwardMethod(StringBuilder sb, ServiceInfo info, Me foreach (var p in method.Parameters.Where(p => !p.IsFileOrValue && (p.IsRequired || (!p.HasDefault && !p.TypeName.EndsWith("?"))) && StringHelper.IsStringType(p.TypeName))) { var paramName = p.IsFromString && p.IsEnum ? (p.ExposedName ?? p.Name) : p.Name; - sb.AppendLine($" Sbroenne.ExcelMcp.Core.Utilities.ParameterTransforms.RequireNotEmpty({paramName}, \"{paramName}\", \"{method.ActionName}\");"); + sb.AppendLine($" PptMcp.Core.Utilities.ParameterTransforms.RequireNotEmpty({paramName}, \"{paramName}\", \"{method.ActionName}\");"); } // Generate transforms (only for FileOrValue parameters) @@ -591,11 +605,11 @@ private static void GenerateForwardMethod(StringBuilder sb, ServiceInfo info, Me { if (p.IsFileOrValue) { - sb.AppendLine($" var resolved{StringHelper.ToPascalCase(p.Name)} = Sbroenne.ExcelMcp.Core.Utilities.ParameterTransforms.ResolveFileOrValue({p.Name}, {p.Name}{p.FileSuffix});"); + sb.AppendLine($" var resolved{StringHelper.ToPascalCase(p.Name)} = PptMcp.Core.Utilities.ParameterTransforms.ResolveFileOrValue({p.Name}, {p.Name}{p.FileSuffix});"); // Validate required FileOrValue parameters AFTER resolution if (p.IsRequired || (!p.HasDefault && !p.TypeName.EndsWith("?"))) { - sb.AppendLine($" Sbroenne.ExcelMcp.Core.Utilities.ParameterTransforms.RequireNotEmpty(resolved{StringHelper.ToPascalCase(p.Name)}, \"{p.Name}\", \"{method.ActionName}\");"); + sb.AppendLine($" PptMcp.Core.Utilities.ParameterTransforms.RequireNotEmpty(resolved{StringHelper.ToPascalCase(p.Name)}, \"{p.Name}\", \"{method.ActionName}\");"); } } // FromString enum parameters: pass raw string to service (no pre-parsing) @@ -815,7 +829,7 @@ private static string GenerateComparisonFile(ServiceInfo info) } else { - sb.AppendLine($" PowerQueryAction.{enumValue} => ExcelToolsBase.ForwardToService(\"{info.Category}.{method.ActionName}\", sessionId),"); + sb.AppendLine($" PowerQueryAction.{enumValue} => PptToolsBase.ForwardToService(\"{info.Category}.{method.ActionName}\", sessionId),"); } } sb.AppendLine(); @@ -847,7 +861,7 @@ private static string GenerateCliManifest(List categories) sb.AppendLine("#nullable enable"); sb.AppendLine("#pragma warning disable CS1591 // Missing XML comment"); sb.AppendLine(); - sb.AppendLine("namespace Sbroenne.ExcelMcp.Generated;"); + sb.AppendLine("namespace PptMcp.Generated;"); sb.AppendLine(); sb.AppendLine("/// "); sb.AppendLine("/// CLI category metadata generated from [ServiceCategory] interfaces."); @@ -895,11 +909,11 @@ private static string GenerateSkillManifest(List categories) sb.AppendLine("#nullable enable"); sb.AppendLine("#pragma warning disable CS1591 // Missing XML comment"); sb.AppendLine(); - sb.AppendLine("namespace Sbroenne.ExcelMcp.Generated;"); + sb.AppendLine("namespace PptMcp.Generated;"); sb.AppendLine(); sb.AppendLine("/// "); sb.AppendLine("/// JSON manifest for skill file generation."); - sb.AppendLine("/// Used by ExcelMcp.Build.Tasks to generate SKILL.md files from templates."); + sb.AppendLine("/// Used by PptMcp.Build.Tasks to generate SKILL.md files from templates."); sb.AppendLine("/// "); sb.AppendLine("internal static class _SkillManifest"); sb.AppendLine("{"); @@ -1009,7 +1023,7 @@ private static string GenerateSkillManifest(List categories) } // NOTE: Data models (ServiceInfo, MethodInfo, ParameterInfo) are now in - // ExcelMcp.Generators.Shared and included as source files + // PptMcp.Generators.Shared and included as source files // ===================================================== // Service Dispatch Generation @@ -1027,7 +1041,7 @@ private static string GenerateDispatchHelpers() sb.AppendLine("#nullable enable"); sb.AppendLine("#pragma warning disable CS1591"); sb.AppendLine(); - sb.AppendLine("namespace Sbroenne.ExcelMcp.Generated;"); + sb.AppendLine("namespace PptMcp.Generated;"); sb.AppendLine(); sb.AppendLine("public static partial class ServiceRegistry"); sb.AppendLine("{"); @@ -1072,7 +1086,7 @@ private static string GenerateDispatchHelpers() sb.AppendLine(" {"); sb.AppendLine(" // Stdin sentinel: if json == \"-\", read from Console.In."); sb.AppendLine(" // This allows piping JSON to avoid PowerShell argument quoting issues:"); - sb.AppendLine(" // echo '[[\"value\",1]]' | excelcli range set-values --values -"); + sb.AppendLine(" // echo '[[\"value\",1]]' | pptcli range set-values --values -"); sb.AppendLine(" if (json.Trim() == \"-\")"); sb.AppendLine(" {"); sb.AppendLine(" json = Console.In.ReadToEnd().Trim();"); @@ -1098,7 +1112,7 @@ private static string GenerateDispatchHelpers() sb.AppendLine(" }"); sb.AppendLine(" throw new System.ArgumentException("); sb.AppendLine(" $\"Invalid JSON for nested collection. Expected 2D array (e.g., [[\\\"a\\\",\\\"b\\\"],[\\\"c\\\",\\\"d\\\"]]) or 1D array (auto-wrapped to single row). Got: {json}\" +"); - sb.AppendLine(" \" Tip: PowerShell strips double-quotes from native executable arguments. Use --values-file to pass JSON from a file, or pipe JSON and use --values - (e.g., echo '[[...]]' | excelcli ... --values -).\");"); + sb.AppendLine(" \" Tip: PowerShell strips double-quotes from native executable arguments. Use --values-file to pass JSON from a file, or pipe JSON and use --values - (e.g., echo '[[...]]' | pptcli ... --values -).\");"); sb.AppendLine(" }"); sb.AppendLine(" }"); sb.AppendLine(); @@ -1120,7 +1134,7 @@ private static string GenerateDispatchHelpers() /// /// Generates the DispatchToCore method for a service category. /// This method routes parsed actions to Core command methods, - /// replacing hand-written Handle*CommandAsync methods in ExcelMcpService. + /// replacing hand-written Handle*CommandAsync methods in PptMcpService. /// private static string GenerateServiceDispatch(ServiceInfo info, string interfaceFullName) { @@ -1130,7 +1144,7 @@ private static string GenerateServiceDispatch(ServiceInfo info, string interface sb.AppendLine("#nullable enable"); sb.AppendLine("#pragma warning disable CS1591"); sb.AppendLine(); - sb.AppendLine("namespace Sbroenne.ExcelMcp.Generated;"); + sb.AppendLine("namespace PptMcp.Generated;"); sb.AppendLine(); sb.AppendLine("public static partial class ServiceRegistry"); sb.AppendLine("{"); @@ -1144,7 +1158,7 @@ private static string GenerateServiceDispatch(ServiceInfo info, string interface sb.AppendLine($" {interfaceFullName} commands,"); sb.AppendLine($" {info.CategoryPascal}Action action,"); if (!info.NoSession) - sb.AppendLine(" Sbroenne.ExcelMcp.ComInterop.Session.IExcelBatch batch,"); + sb.AppendLine(" PptMcp.ComInterop.Session.IPptBatch batch,"); sb.AppendLine(" string? argsJson)"); sb.AppendLine(" {"); sb.AppendLine(" switch (action)"); @@ -1224,7 +1238,7 @@ private static void GenerateDispatchCase(StringBuilder sb, ServiceInfo info, Met // Build method call argument list var callArgs = new List(); - // Only pass batch if the method actually has an IExcelBatch parameter + // Only pass batch if the method actually has an IPptBatch parameter if (!info.NoSession && method.HasBatchParameter) callArgs.Add("batch"); @@ -1245,7 +1259,7 @@ private static void GenerateDispatchCase(StringBuilder sb, ServiceInfo info, Met // Inject ambient progress context LAST (matches Core method signature where IProgress is the last parameter) if (method.HasProgressParameter) - callArgs.Add("Sbroenne.ExcelMcp.ComInterop.ProgressContext.Current"); + callArgs.Add("PptMcp.ComInterop.ProgressContext.Current"); var isVoid = method.ReturnType == "void"; if (isVoid) diff --git a/src/ExcelMcp.McpServer/.mcp/server.json b/src/PptMcp.McpServer/.mcp/server.json similarity index 54% rename from src/ExcelMcp.McpServer/.mcp/server.json rename to src/PptMcp.McpServer/.mcp/server.json index 740b23dd..a6bb2f52 100644 --- a/src/ExcelMcp.McpServer/.mcp/server.json +++ b/src/PptMcp.McpServer/.mcp/server.json @@ -1,13 +1,13 @@ { "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", - "name": "io.github.sbroenne/mcp-server-excel", - "title": "MCP Server for Excel", - "description": "Excel automation for AI - Sheets, Power Query, DAX, VBA, Tables, Ranges and more. Windows only.", + "name": "io.github.trsdn/mcp-server-ppt", + "title": "MCP Server for PowerPoint", + "description": "PowerPoint automation for AI - Slides, Shapes, Text, Charts and more. Windows only.", "version": "1.0.0", "packages": [ { "registryType": "nuget", - "identifier": "Sbroenne.ExcelMcp.McpServer", + "identifier": "PptMcp.McpServer", "version": "1.0.0", "transport": { "type": "stdio" @@ -17,7 +17,7 @@ } ], "repository": { - "url": "https://github.com/sbroenne/mcp-server-excel", + "url": "https://github.com/trsdn/mcp-server-ppt", "source": "github" } } diff --git a/src/PptMcp.McpServer/GlobalUsings.cs b/src/PptMcp.McpServer/GlobalUsings.cs new file mode 100644 index 00000000..40e76acc --- /dev/null +++ b/src/PptMcp.McpServer/GlobalUsings.cs @@ -0,0 +1,2 @@ +// Global usings for PptMcp.McpServer +global using PptMcp.Generated; diff --git a/src/ExcelMcp.McpServer/Infrastructure/McpServerVersionChecker.cs b/src/PptMcp.McpServer/Infrastructure/McpServerVersionChecker.cs similarity index 96% rename from src/ExcelMcp.McpServer/Infrastructure/McpServerVersionChecker.cs rename to src/PptMcp.McpServer/Infrastructure/McpServerVersionChecker.cs index 0c18463c..5b2a41af 100644 --- a/src/ExcelMcp.McpServer/Infrastructure/McpServerVersionChecker.cs +++ b/src/PptMcp.McpServer/Infrastructure/McpServerVersionChecker.cs @@ -1,6 +1,6 @@ using System.Reflection; -namespace Sbroenne.ExcelMcp.McpServer.Infrastructure; +namespace PptMcp.McpServer.Infrastructure; /// /// Checks for MCP Server updates and provides version information. diff --git a/src/ExcelMcp.McpServer/Infrastructure/NuGetVersionChecker.cs b/src/PptMcp.McpServer/Infrastructure/NuGetVersionChecker.cs similarity index 94% rename from src/ExcelMcp.McpServer/Infrastructure/NuGetVersionChecker.cs rename to src/PptMcp.McpServer/Infrastructure/NuGetVersionChecker.cs index 18e41977..0b825446 100644 --- a/src/ExcelMcp.McpServer/Infrastructure/NuGetVersionChecker.cs +++ b/src/PptMcp.McpServer/Infrastructure/NuGetVersionChecker.cs @@ -1,14 +1,14 @@ using System.Net.Http.Json; using System.Text.Json.Serialization; -namespace Sbroenne.ExcelMcp.McpServer.Infrastructure; +namespace PptMcp.McpServer.Infrastructure; /// /// Checks NuGet for the latest version of the MCP Server package. /// internal static class NuGetVersionChecker { - private const string PackageId = "sbroenne.excelmcp.mcpserver"; + private const string PackageId = "PptMcp.mcpserver"; private const string NuGetIndexUrl = $"https://api.nuget.org/v3-flatcontainer/{PackageId}/index.json"; private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); diff --git a/src/ExcelMcp.McpServer/ExcelMcp.McpServer.csproj b/src/PptMcp.McpServer/PptMcp.McpServer.csproj similarity index 75% rename from src/ExcelMcp.McpServer/ExcelMcp.McpServer.csproj rename to src/PptMcp.McpServer/PptMcp.McpServer.csproj index 75f3f776..771bfa5a 100644 --- a/src/ExcelMcp.McpServer/ExcelMcp.McpServer.csproj +++ b/src/PptMcp.McpServer/PptMcp.McpServer.csproj @@ -2,14 +2,14 @@ Exe - net10.0-windows + net9.0-windows enable enable - Sbroenne.ExcelMcp.McpServer - Sbroenne.ExcelMcp.McpServer + PptMcp.McpServer + PptMcp.McpServer $(NoWarn);CS1591 @@ -20,19 +20,19 @@ - Sbroenne.ExcelMcp.McpServer + PptMcp.McpServer - MCP Server for Excel - Excel automation for AI assistants - manage Sheets, Power Query, DAX, VBA, Tables, Ranges, Formatting, Validation and more. Requires Excel to be installed. Only runs on Windows. - mcp;model-context-protocol;excel;microsoft;office;spreadsheet;automation;power-query;m-language;dax;data-model;power-pivot;vba;macro;table;copilot;ai;github-copilot;data-analysis + MCP Server for PowerPoint + PowerPoint automation for AI assistants - manage Slides, Shapes, Text, Charts, and more. Requires PowerPoint to be installed. Only runs on Windows. + mcp;model-context-protocol;powerpoint;microsoft;office;presentation;automation;slides;shapes;copilot;ai;github-copilot README.md - See https://github.com/sbroenne/mcp-server-excel/releases for release notes + See https://github.com/trsdn/mcp-server-ppt/releases for release notes false true - mcp-excel + mcp-ppt false @@ -42,9 +42,6 @@ true - - - $(APPINSIGHTS_CONNECTION_STRING) + @@ -84,7 +81,7 @@ - + @@ -97,20 +94,20 @@ // Keys are skill filenames (without .md), values are LLM-friendly descriptions var descriptionOverrides = new System.Collections.Generic.Dictionary { - { "anti-patterns", "Common mistakes to avoid when using Excel MCP tools" }, - { "behavioral-rules", "Rules and constraints for Excel MCP operations" }, + { "anti-patterns", "Common mistakes to avoid when using PowerPoint MCP tools" }, + { "behavioral-rules", "Rules and constraints for PowerPoint MCP operations" }, { "chart", "Chart creation, types, positioning, and multi-chart layouts" }, { "conditionalformat", "Conditional formatting rule types, parameters, and examples" }, { "dashboard", "Dashboard and report best practices: Tables, formatting, charts, verification" }, { "datamodel", "Data Model (Power Pivot) operations, DAX measures, and prerequisites" }, - { "dmv-reference", "DMV query reference for Excel's embedded Analysis Services (TMSCHEMA catalog)" }, + { "dmv-reference", "DMV query reference for PowerPoint's embedded Analysis Services (TMSCHEMA catalog)" }, { "m-code-syntax", "Power Query M code syntax: column quoting, named ranges, query chaining" }, { "pivottable", "PivotTable creation, fields, calculated items, and required parameters" }, { "powerquery", "Power Query M code workflows, refresh patterns, and development tips" }, { "range", "Range number formats, locale-aware formatting, and format codes" }, { "screenshot", "Screenshot capture for visual verification of charts and dashboards" }, { "slicer", "Slicer types, creation patterns, and multi-select filtering" }, - { "table", "Excel Table operations, Data Model integration, and column management" }, + { "table", "PowerPoint Table operations, Data Model integration, and column management" }, { "workflows", "Key constraints, batch operations, and session management patterns" }, { "worksheet", "Worksheet operations including cross-file copy and move" }, }; @@ -123,20 +120,20 @@ sb.AppendLine("using System.Reflection;"); sb.AppendLine("using Microsoft.Extensions.AI;"); sb.AppendLine("using ModelContextProtocol.Server;"); sb.AppendLine(); -sb.AppendLine("namespace Sbroenne.ExcelMcp.McpServer.Prompts;"); +sb.AppendLine("namespace PptMcp.McpServer.Prompts;"); sb.AppendLine(); sb.AppendLine("/// "); sb.AppendLine("/// Auto-generated MCP prompts from skills/shared/*.md files."); sb.AppendLine("/// Ensures Claude Desktop and other MCP clients get the same guidance as skill-based clients."); sb.AppendLine("/// "); sb.AppendLine("[McpServerPromptType]"); -sb.AppendLine("public static class ExcelSkillPrompts"); +sb.AppendLine("public static class PptSkillPrompts"); sb.AppendLine("{"); -sb.AppendLine(" private static readonly Assembly _assembly = typeof(ExcelSkillPrompts).Assembly;"); +sb.AppendLine(" private static readonly Assembly _assembly = typeof(PptSkillPrompts).Assembly;"); sb.AppendLine(); sb.AppendLine(" private static string LoadResource(string fileName)"); sb.AppendLine(" {"); -sb.AppendLine(" var name = $\"Sbroenne.ExcelMcp.McpServer.Prompts.Content.Skills.{fileName}\";"); +sb.AppendLine(" var name = $\"PptMcp.McpServer.Prompts.Content.Skills.{fileName}\";"); sb.AppendLine(" using var stream = _assembly.GetManifestResourceStream(name)"); sb.AppendLine(" ?? _assembly.GetManifestResourceStream(name.Replace(\"-\", \"_\"));"); sb.AppendLine(" if (stream == null) throw new FileNotFoundException($\"Embedded resource not found: {name}\");"); @@ -212,7 +209,7 @@ System.IO.File.WriteAllText(OutputFile, sb.ToString()); $(MSBuildProjectDirectory)\..\..\skills\shared - $(IntermediateOutputPath)ExcelSkillPrompts.g.cs + $(IntermediateOutputPath)PptSkillPrompts.g.cs @@ -225,24 +222,6 @@ System.IO.File.WriteAllText(OutputFile, sb.ToString()); - - - $(IntermediateOutputPath)TelemetryConfig.g.cs - // Auto-generated at build time - do not edit -namespace Sbroenne.ExcelMcp.McpServer.Telemetry%3B - -internal static class TelemetryConfig -{ - public const string ConnectionString = "$(AppInsightsConnectionString)"%3B -} - - - - - - - - @@ -270,35 +249,32 @@ internal static class TelemetryConfig - + true - + - - - + - - - all runtime; build; native; contentfiles; analyzers @@ -324,18 +300,18 @@ internal static class TelemetryConfig - + + Inputs="$(MSBuildProjectDirectory)\..\..\skills\templates\SKILL.mcp.sbn;$(MSBuildProjectDirectory)\..\PptMcp.Core\obj\GeneratedFiles\PptMcp.Generators\PptMcp.Generators.ServiceRegistryGenerator\_SkillManifest.g.cs" + Outputs="$(MSBuildProjectDirectory)\..\..\skills\ppt-mcp\SKILL.md"> $(MSBuildProjectDirectory)\..\..\skills\templates\SKILL.mcp.sbn - $(MSBuildProjectDirectory)\..\..\skills\excel-mcp\SKILL.md + $(MSBuildProjectDirectory)\..\..\skills\ppt-mcp\SKILL.md - $(MSBuildProjectDirectory)\..\ExcelMcp.Core\obj\GeneratedFiles\ExcelMcp.Generators\Sbroenne.ExcelMcp.Generators.ServiceRegistryGenerator\_SkillManifest.g.cs + $(MSBuildProjectDirectory)\..\PptMcp.Core\obj\GeneratedFiles\PptMcp.Generators\PptMcp.Generators.ServiceRegistryGenerator\_SkillManifest.g.cs $(MSBuildProjectDirectory)\..\..\skills\shared - $(MSBuildProjectDirectory)\..\..\skills\excel-mcp\references + $(MSBuildProjectDirectory)\..\..\skills\ppt-mcp\references diff --git a/src/ExcelMcp.McpServer/Program.cs b/src/PptMcp.McpServer/Program.cs similarity index 56% rename from src/ExcelMcp.McpServer/Program.cs rename to src/PptMcp.McpServer/Program.cs index 7fdbb77f..006bc375 100644 --- a/src/ExcelMcp.McpServer/Program.cs +++ b/src/PptMcp.McpServer/Program.cs @@ -1,18 +1,15 @@ using System.IO.Pipelines; using System.Reflection; -using Microsoft.ApplicationInsights; -using Microsoft.ApplicationInsights.WorkerService; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Sbroenne.ExcelMcp.McpServer.Telemetry; -namespace Sbroenne.ExcelMcp.McpServer; +namespace PptMcp.McpServer; /// -/// ExcelMCP Model Context Protocol (MCP) Server. -/// Provides resource-based tools for AI assistants to automate Excel operations. +/// PptMcp Model Context Protocol (MCP) Server. +/// Provides resource-based tools for AI assistants to automate PowerPoint operations. /// public class Program { @@ -66,14 +63,14 @@ public static async Task Main(string[] args) } } - // Register global exception handlers for unhandled exceptions (telemetry) + // Register global exception handlers for unhandled exceptions RegisterGlobalExceptionHandlers(); var builder = Host.CreateApplicationBuilder(args); // Disable FileSystemWatcher for config file reload. // Host.CreateApplicationBuilder() enables reloadOnChange:true by default, creating a - // FileSystemWatcher for appsettings.json. Under file I/O storms (Excel temp files, lock + // FileSystemWatcher for appsettings.json. Under file I/O storms (PowerPoint temp files, lock // files), this watcher fires ParseEventBufferAndNotifyForEach in a tight loop on the // threadpool, consuming ~85% CPU. Since MCP server config never changes at runtime, // disable reload entirely to eliminate the watcher. @@ -96,66 +93,26 @@ public static async Task Main(string[] args) }); builder.Logging.SetMinimumLevel(LogLevel.Warning); - // Configure Application Insights - ConfigureTelemetry(builder); - // Configure MCP Server - use test transport if configured, otherwise stdio var mcpBuilder = builder.Services .AddMcpServer(options => { options.ServerInfo = new() { - Name = "excel-mcp", + Name = "ppt-mcp", Version = typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0" }; // Server-wide instructions for LLMs - helps with tool selection and workflow understanding options.ServerInstructions = """ - ExcelMCP automates Microsoft Excel via COM interop. + PptMcp automates Microsoft PowerPoint via COM interop. - CRITICAL: File must be CLOSED in Excel desktop app (COM requires exclusive access). + CRITICAL: File must be CLOSED in PowerPoint desktop app (COM requires exclusive access). SESSION LIFECYCLE: - 1. file(action:'open') → returns sessionId - 2. Use sessionId with ALL subsequent tools + 1. file(action:'open') → returns session_id + 2. Use session_id with ALL subsequent tools 3. file(action:'close', save:true/false) → ONLY when completely done - - CALCULATION MODE: - - When a task mentions manual/automatic calculation or explicit recalculation, you MUST use calculation_mode. - - Sequence: set-mode manual → perform writes → calculate (scope: workbook) → set-mode automatic. - - Use get-mode when user asks for current calculation mode. - - CRITICAL - DO NOT CLOSE SESSION PREMATURELY: - - Server automatically tracks active operations per session - - Close will be BLOCKED if operations are still running (returns error with count) - - Wait for error message to clear before retrying close - - This prevents data loss from closing mid-operation - - SHOW EXCEL — "Agent Mode" (hero feature): - - Default is show:false (hidden) — but ASK the user before starting multi-step tasks - - Excel MCP supports "Agent Mode": users watch AI work in Excel in real-time - - When starting a task, present two clear action card choices: - (1) "Watch me work" — Show Excel side-by-side so you see every change live. Slightly slower because Excel renders each update. - (2) "Work in background" — Keep Excel hidden for maximum speed. You won't see changes until done, but operations complete faster. - - Each choice should clearly state its tradeoff so the user can make an informed decision - - Skip asking only when: user already stated a preference, or it's a simple one-shot operation - - If user picks "Watch me work": window(action:'show') + window(action:'arrange', preset:'right-half') - - Use window(action:'set-status-bar', text:'...') to show what you're doing in Excel's status bar - - Use window(action:'clear-status-bar') when done - - Use window(action:'hide') to hide Excel again - - WHEN TO SKIP ASKING: - - User says "show me", "watch", "let me see" — show immediately, no need to ask - - User says "just do it", "work in background" — keep hidden, no need to ask - - Simple one-shot operations (read a value, check a formula) — keep hidden - - If user doesn't respond to the question, keep hidden - - WHEN Excel is visible — ASK BEFORE CLOSING: - - If Excel is visible, the user is actively watching - - ALWAYS ask before closing: "Would you like me to save and close, or keep it open?" - - User may want to inspect results or make manual changes - - Do NOT auto-close visible Excel sessions - - Check visibility with window(action:'get-info') if unsure """; }) .WithToolsFromAssembly() @@ -176,10 +133,7 @@ 3. file(action:'close', save:true/false) → ONLY when completely done var host = builder.Build(); - // Initialize telemetry client for static access - InitializeTelemetryClient(host.Services); - - // Note: Update checks are handled by ExcelMCP Service (shown via Windows notification) + // Note: Update checks are handled by PptMcp Service (shown via Windows notification) // to avoid duplicate notifications when running in unified package mode try @@ -196,82 +150,40 @@ 3. file(action:'close', save:true/false) → ONLY when completely done #pragma warning disable CA1031 // Catch general exception - this is a top-level handler that must not crash catch (Exception ex) { - // Track MCP SDK/transport errors (protocol errors, serialization errors, etc.) - ExcelMcpTelemetry.TrackUnhandledException(ex, "McpServer.RunAsync"); - ExcelMcpTelemetry.Flush(); // Ensure telemetry is sent before exit - // Return exit code 1 for fatal errors (FR-024, SC-015a) // Do NOT re-throw - deterministic exit code is more important for callers + Console.Error.WriteLine($"[PptMcp] Fatal error: {ex.Message}"); return 1; } #pragma warning restore CA1031 finally { - // CRITICAL: Auto-save all sessions and clean up Excel processes on shutdown. + // CRITICAL: Auto-save all sessions and clean up PowerPoint processes on shutdown. // Without this, MCP client disconnect or process exit silently discards all unsaved work. ServiceBridge.ServiceBridge.Dispose(); } } - /// - /// Initializes the static TelemetryClient from DI container. - /// - private static void InitializeTelemetryClient(IServiceProvider services) - { - // Resolve TelemetryClient from DI and store for static access - // Worker Service SDK manages the TelemetryClient lifecycle including flush on shutdown - var telemetryClient = services.GetService(); - if (telemetryClient != null) - { - ExcelMcpTelemetry.SetTelemetryClient(telemetryClient); - } - } - - /// - /// Configures Application Insights Worker Service SDK for telemetry. - /// Uses AddApplicationInsightsTelemetryWorkerService() for proper host integration. - /// Enables Users/Sessions/Funnels/User Flows analytics in Azure Portal. - /// - private static void ConfigureTelemetry(HostApplicationBuilder builder) + private static void RegisterGlobalExceptionHandlers() { - var connectionString = ExcelMcpTelemetry.GetConnectionString(); - if (string.IsNullOrEmpty(connectionString)) + // Handle exceptions that escape all catch blocks + AppDomain.CurrentDomain.UnhandledException += (sender, e) => { - return; // No connection string available (local dev build) - } + if (e.ExceptionObject is Exception ex) + { + Console.Error.WriteLine($"[PptMcp] Unhandled exception: {ex.Message}"); + } + }; - // Configure Application Insights Worker Service SDK - // This provides: - // - Proper DI integration with IHostApplicationLifetime - // - Automatic dependency tracking - // - Automatic performance counter collection (where available) - // - Proper telemetry channel with ServerTelemetryChannel (retries, local storage) - // - Automatic flush on host shutdown - var aiOptions = new ApplicationInsightsServiceOptions + // Handle unobserved task exceptions + TaskScheduler.UnobservedTaskException += (sender, e) => { - // Set connection string if available - ConnectionString = connectionString, - - // Disable features not needed for MCP server (reduces overhead) - EnableHeartbeat = true, // Useful for monitoring server health - EnableAdaptiveSampling = true, // Helps manage telemetry volume - EnableQuickPulseMetricStream = false, // Live Metrics not needed for CLI tool - EnablePerformanceCounterCollectionModule = false, // Perf counters not useful for short-lived CLI - EnableEventCounterCollectionModule = false, // Event counters not needed - - // Disable dependency tracking for HTTP calls - EnableDependencyTrackingTelemetryModule = false, + Console.Error.WriteLine($"[PptMcp] Unobserved task exception: {e.Exception.Message}"); }; - - builder.Services.AddApplicationInsightsTelemetryWorkerService(aiOptions); - - // Add custom telemetry initializer for User.Id and Session.Id - // This enables the Users and Sessions blades in Azure Portal - builder.Services.AddSingleton(); } /// - /// Registers global exception handlers to capture unhandled exceptions. + /// Registers assembly resolver for office.dll (Microsoft.Office.Core). /// private static void RegisterOfficeAssemblyResolver() { @@ -318,10 +230,8 @@ private static void RegisterOfficeAssemblyResolver() var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); string[] officeDirs = [ - Path.Combine(programFiles, @"Microsoft Office\root\Office16\ADDINS\PowerPivot Excel Add-inv16"), - Path.Combine(programFiles, @"Microsoft Office\root\Office16\ADDINS\PowerPivot Excel Add-in"), - Path.Combine(programFilesX86, @"Microsoft Office\root\Office16\ADDINS\PowerPivot Excel Add-inv16"), - Path.Combine(programFilesX86, @"Microsoft Office\root\Office16\ADDINS\PowerPivot Excel Add-in"), + Path.Combine(programFiles, @"Microsoft Office\root\Office16"), + Path.Combine(programFilesX86, @"Microsoft Office\root\Office16"), ]; foreach (var dir in officeDirs) { @@ -333,25 +243,6 @@ private static void RegisterOfficeAssemblyResolver() return null; } - private static void RegisterGlobalExceptionHandlers() - { - // Handle exceptions that escape all catch blocks - AppDomain.CurrentDomain.UnhandledException += (sender, e) => - { - if (e.ExceptionObject is Exception ex) - { - ExcelMcpTelemetry.TrackUnhandledException(ex, "AppDomain.UnhandledException"); - } - }; - - // Handle unobserved task exceptions - TaskScheduler.UnobservedTaskException += (sender, e) => - { - ExcelMcpTelemetry.TrackUnhandledException(e.Exception, "TaskScheduler.UnobservedTaskException"); - // Don't observe it - let the runtime handle it - }; - } - /// /// Shows help information. /// @@ -359,13 +250,12 @@ private static void ShowHelp() { var version = typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0"; Console.WriteLine($""" - Excel MCP Server v{version} + PowerPoint MCP Server v{version} - An MCP (Model Context Protocol) server for Microsoft Excel automation. - Provides 22 tools with 195+ operations for AI assistants. + An MCP (Model Context Protocol) server for Microsoft PowerPoint automation. Usage: - Sbroenne.ExcelMcp.McpServer.exe [options] + PptMcp.McpServer.exe [options] Options: -h, --help Show this help message @@ -375,13 +265,7 @@ Sbroenne.ExcelMcp.McpServer.exe [options] Requirements: - Windows x64 - - Microsoft Excel 2016 or later (desktop version) - - Documentation: - https://sbroenne.github.io/mcp-server-excel/ - - Source: - https://github.com/sbroenne/mcp-server-excel + - Microsoft PowerPoint 2016 or later (desktop version) """); } @@ -391,7 +275,7 @@ Sbroenne.ExcelMcp.McpServer.exe [options] private static async Task ShowVersionAsync() { var currentVersion = Infrastructure.McpServerVersionChecker.GetCurrentVersion(); - Console.WriteLine($"Excel MCP Server v{currentVersion}"); + Console.WriteLine($"PowerPoint MCP Server v{currentVersion}"); // Check for updates (non-blocking, 5-second timeout) var latestVersion = await Infrastructure.McpServerVersionChecker.CheckForUpdateAsync(); @@ -399,8 +283,8 @@ private static async Task ShowVersionAsync() { Console.WriteLine(); Console.WriteLine($"Update available: {currentVersion} -> {latestVersion}"); - Console.WriteLine("Run: dotnet tool update --global Sbroenne.ExcelMcp.McpServer"); - Console.WriteLine("Release notes: https://github.com/sbroenne/mcp-server-excel/releases/latest"); + Console.WriteLine("Run: dotnet tool update --global PptMcp.McpServer"); + Console.WriteLine("Release notes: https://github.com/trsdn/mcp-server-ppt/releases/latest"); } } } diff --git a/src/ExcelMcp.McpServer/Progress/McpProgressAdapter.cs b/src/PptMcp.McpServer/Progress/McpProgressAdapter.cs similarity index 89% rename from src/ExcelMcp.McpServer/Progress/McpProgressAdapter.cs rename to src/PptMcp.McpServer/Progress/McpProgressAdapter.cs index f91a047c..629ce079 100644 --- a/src/ExcelMcp.McpServer/Progress/McpProgressAdapter.cs +++ b/src/PptMcp.McpServer/Progress/McpProgressAdapter.cs @@ -1,7 +1,7 @@ using ModelContextProtocol; -using Sbroenne.ExcelMcp.ComInterop; +using PptMcp.ComInterop; -namespace Sbroenne.ExcelMcp.McpServer.Progress; +namespace PptMcp.McpServer.Progress; /// /// Adapts the MCP SDK's to our diff --git a/src/ExcelMcp.McpServer/Progress/ProgressReporter.cs b/src/PptMcp.McpServer/Progress/ProgressReporter.cs similarity index 97% rename from src/ExcelMcp.McpServer/Progress/ProgressReporter.cs rename to src/PptMcp.McpServer/Progress/ProgressReporter.cs index 31a7c10c..e0cb4ebf 100644 --- a/src/ExcelMcp.McpServer/Progress/ProgressReporter.cs +++ b/src/PptMcp.McpServer/Progress/ProgressReporter.cs @@ -1,6 +1,6 @@ using System.Text.Json; -namespace Sbroenne.ExcelMcp.McpServer.Progress; +namespace PptMcp.McpServer.Progress; /// /// Progress reporting helper for MCP server operations. diff --git a/src/PptMcp.McpServer/README.md b/src/PptMcp.McpServer/README.md new file mode 100644 index 00000000..7b1b0e4b --- /dev/null +++ b/src/PptMcp.McpServer/README.md @@ -0,0 +1,117 @@ +# PptMcp - Model Context Protocol Server for PowerPoint + + +mcp-name: io.github.trsdn/mcp-server-ppt + +[![NuGet](https://img.shields.io/nuget/v/PptMcp.McpServer.svg)](https://www.nuget.org/packages/PptMcp.McpServer) +[![NuGet Downloads](https://img.shields.io/nuget/dt/PptMcp.McpServer.svg)](https://www.nuget.org/packages/PptMcp.McpServer) +[![GitHub](https://img.shields.io/badge/GitHub-Repository-blue.svg)](https://github.com/trsdn/mcp-server-ppt) + +**Control PowerPoint with Natural Language** through AI assistants like GitHub Copilot, Claude, and ChatGPT. This MCP server enables AI-powered PowerPoint automation for slides, shapes, text, charts, and more. + +➡️ **[Learn more and see examples](https://trsdn.github.io/mcp-server-ppt/)** + +**🛡️ 100% Safe - Uses PowerPoint's Native COM API** + +Unlike third-party libraries that manipulate `.pptx` files (risking corruption), PptMcp uses **PowerPoint's official COM automation API**. This guarantees zero risk of file corruption while you work interactively with live PowerPoint files - see your changes happen in real-time. + +**🔗 Unified Service Architecture** - The MCP Server forwards all requests to the shared PptMcp Service, enabling CLI and MCP to share sessions transparently. + +**CLI also available:** The MCP Server tool (`mcp-ppt`) and CLI tool (`pptcli`) are published as separate .NET tools. Install `PptMcp.McpServer` for MCP clients, and optionally install `PptMcp.CLI` for scripting/RPA workflows. + +**Requirements:** Windows OS + PowerPoint 2016+ + +## 🚀 Installation + +**Quick Setup Options:** + +1. **VS Code Extension** - [One-click install](https://marketplace.visualstudio.com/items?itemName=trsdn.ppt-mcp) for GitHub Copilot +2. **Manual Install** - Works with Claude Desktop, Cursor, Cline, Windsurf, and other MCP clients +3. **MCP Registry** - Find us at [registry.modelcontextprotocol.io](https://registry.modelcontextprotocol.io/servers/io.github.trsdn/mcp-server-ppt) + +**Manual Installation (All MCP Clients):** + +Requires .NET 10 Runtime or SDK + +```powershell +# Install MCP Server tool +dotnet tool install --global PptMcp.McpServer + +# Optional: install CLI tool separately +dotnet tool install --global PptMcp.CLI +``` + +**Supported AI Assistants:** +- ✅ GitHub Copilot (VS Code, Visual Studio) +- ✅ Claude Desktop +- ✅ Cursor +- ✅ Cline (VS Code Extension) +- ✅ Windsurf +- ✅ Any MCP-compatible client + +📖 **Detailed setup instructions:** [Installation Guide](https://github.com/trsdn/mcp-server-ppt/blob/main/docs/INSTALLATION.md) + +🎯 **Quick config examples:** [examples/mcp-configs/](https://github.com/trsdn/mcp-server-ppt/tree/main/examples/mcp-configs) + +## 🛠️ What You Can Do + +**25 specialized tools with 225 operations:** + +- 🔄 **Power Query** (1 tool, 11 ops) - Atomic workflows, M code management, load destinations +- 📊 **Data Model/DAX** (2 tools, 18 ops) - Measures, relationships, model structure +- 🎨 **PowerPoint Tables** (2 tools, 27 ops) - Lifecycle, filtering, sorting, structured references +- 📈 **PivotTables** (3 tools, 30 ops) - Creation, fields, aggregations, calculated members/fields +- 📉 **Charts** (2 tools, 26 ops) - Create, configure, series, formatting, data labels, trendlines +- 📝 **VBA** (1 tool, 6 ops) - Modules, execution, version control +- 📋 **Ranges** (4 tools, 42 ops) - Values, formulas, formatting, validation, protection +- 📄 **Slides** (2 tools, 16 ops) - Lifecycle, colors, visibility, cross-presentation moves +- 🔌 **Connections** (1 tool, 9 ops) - OLEDB/ODBC management and refresh +- 🏷️ **Named Ranges** (1 tool, 6 ops) - Parameters and configuration +- 📁 **Files** (1 tool, 6 ops) - Session management, presentation creation, IRM/AIP-protected file support +- 🧮 **Calculation Mode** (1 tool, 3 ops) - Get/set calculation mode and trigger recalculation +- 🎚️ **Slicers** (1 tool, 8 ops) - Interactive filtering for PivotTables and Tables +- 🎨 **Conditional Formatting** (1 tool, 2 ops) - Rules and clearing +- 📸 **Screenshot** (1 tool, 2 ops) - Capture ranges/sheets as PNG for visual verification +- 🪧 **Window Management** (1 tool, 9 ops) - Show/hide PowerPoint, arrange, position, status bar feedback + +📚 **[Complete Feature Reference →](../../FEATURES.md)** - Detailed documentation of all 225 operations + +**AI-Powered Workflows:** +- 💬 Natural language PowerPoint commands through GitHub Copilot, Claude, or ChatGPT +- 🔄 Optimize Power Query M code for performance and readability +- 📊 Build complex DAX measures with AI guidance +- 📋 Automate repetitive data transformations and formatting +- 👀 **Show PowerPoint Mode** - Say "Show me PowerPoint while you work" to watch changes live + + +--- + +## 💡 Example Use Cases + +**"Create a sales tracker with Date, Product, Quantity, Unit Price, and Total columns"** +→ AI creates the presentation, adds headers, enters sample data, and builds formulas + +**"Create a PivotTable from this data showing total sales by Product, then add a chart"** +→ AI creates PivotTable, configures fields, and adds a linked visualization + +**"Import products.csv with Power Query, load to Data Model, create a Total Revenue measure"** +→ AI imports data, adds to Power Pivot, and creates DAX measures for analysis + +**"Create a slicer for the Region field so I can filter interactively"** +→ AI adds slicers connected to PivotTables or Tables for point-and-click filtering + +**"Put this data in A1: Name, Age / Alice, 30 / Bob, 25"** +→ AI writes data directly to cells using natural delimiters you provide + +--- + +## 📋 Additional Resources + +- **[GitHub Repository](https://github.com/trsdn/mcp-server-ppt)** - Source code, issues, discussions +- **[Installation Guide](https://github.com/trsdn/mcp-server-ppt/blob/main/docs/INSTALLATION.md)** - Detailed setup for all platforms +- **[VS Code Extension](https://marketplace.visualstudio.com/items?itemName=trsdn.ppt-mcp)** - One-click installation +- **[CLI Documentation](https://github.com/trsdn/mcp-server-ppt/blob/main/src/PptMcp.CLI/README.md)** - Comprehensive commands for RPA and CI/CD automation + +**License:** MIT +**Platform:** Windows only (requires PowerPoint 2016+) +**Support:** [GitHub Issues](https://github.com/trsdn/mcp-server-ppt/issues) diff --git a/src/ExcelMcp.McpServer/Resources/ExcelResourceProvider.cs b/src/PptMcp.McpServer/Resources/PptResourceProvider.cs similarity index 61% rename from src/ExcelMcp.McpServer/Resources/ExcelResourceProvider.cs rename to src/PptMcp.McpServer/Resources/PptResourceProvider.cs index 60467e90..759bb682 100644 --- a/src/ExcelMcp.McpServer/Resources/ExcelResourceProvider.cs +++ b/src/PptMcp.McpServer/Resources/PptResourceProvider.cs @@ -2,18 +2,18 @@ using System.Text.Json; using ModelContextProtocol.Server; -namespace Sbroenne.ExcelMcp.McpServer.Resources; +namespace PptMcp.McpServer.Resources; /// -/// MCP resources for documenting available Excel workbook URIs. -/// Resources help LLMs understand what can be inspected in Excel workbooks. +/// MCP resources for documenting available PowerPoint presentation URIs. +/// Resources help LLMs understand what can be inspected in PowerPoint presentations. /// /// NOTE: MCP SDK 0.4.0-preview.2 does NOT support McpServerResourceTemplate yet. -/// Dynamic URI patterns (excel://{path}/queries/{name}) will be added when SDK supports it. -/// For now, use tools (powerquery list, etc.) for actual data retrieval. +/// Dynamic URI patterns (ppt://{path}/slides/{name}) will be added when SDK supports it. +/// For now, use tools (slide list, etc.) for actual data retrieval. /// [McpServerResourceType] -public static class ExcelResourceProvider +public static class PptResourceProvider { private static readonly JsonSerializerOptions JsonOptions = new() { @@ -22,16 +22,16 @@ public static class ExcelResourceProvider }; /// - /// Documents available Excel workbook resource URIs. + /// Documents available PowerPoint presentation resource URIs. /// - [McpServerResource(UriTemplate = "excel://help/resources")] - [Description("Guide to available Excel workbook resources")] + [McpServerResource(UriTemplate = "ppt://help/resources")] + [Description("Guide to available PowerPoint presentation resources")] public static Task GetResourceGuide() { var guide = new { - title = "Excel Workbook Resources", - description = "URI patterns for inspecting Excel workbooks", + title = "PowerPoint Presentation Resources", + description = "URI patterns for inspecting PowerPoint presentations", note = "Use tools to retrieve actual data (MCP SDK resource templates not yet supported)", resourceTypes = new[] { @@ -39,73 +39,73 @@ public static Task GetResourceGuide() { type = "Power Queries", toolAction = "Use powerquery tool with action='list' to see all queries", - example = "powerquery(action: 'list', excelPath: 'workbook.xlsx')" + example = "powerquery(action: 'list', presentationPath: 'presentation.pptx')" }, new { - type = "Worksheets", - toolAction = "Use worksheet tool with action='list' to see all worksheets", - example = "worksheet(action: 'list', excelPath: 'workbook.xlsx')" + type = "Slides", + toolAction = "Use slide tool with action='list' to see all slides", + example = "slide(action: 'list', presentationPath: 'presentation.pptx')" }, new { type = "Parameters (Named Ranges)", toolAction = "Use namedrange tool with action='list' to see all parameters", - example = "namedrange(action: 'list', excelPath: 'workbook.xlsx')" + example = "namedrange(action: 'list', presentationPath: 'presentation.pptx')" }, new { type = "Data Model Tables", toolAction = "Use datamodel tool with action='list-tables'", - example = "datamodel(action: 'list-tables', excelPath: 'workbook.xlsx')" + example = "datamodel(action: 'list-tables', presentationPath: 'presentation.pptx')" }, new { type = "DAX Measures", toolAction = "Use datamodel tool with action='list-measures'", - example = "datamodel(action: 'list-measures', excelPath: 'workbook.xlsx')" + example = "datamodel(action: 'list-measures', presentationPath: 'presentation.pptx')" }, new { type = "VBA Modules", toolAction = "Use vba tool with action='list'", - example = "vba(action: 'list', excelPath: 'workbook.xlsm')" + example = "vba(action: 'list', presentationPath: 'presentation.pptm')" }, new { - type = "Excel Tables", + type = "Slide Tables", toolAction = "Use table tool with action='list'", - example = "table(action: 'list', excelPath: 'workbook.xlsx')" + example = "table(action: 'list', presentationPath: 'presentation.pptx')" }, new { type = "Connections", toolAction = "Use connection tool with action='list'", - example = "connection(action: 'list', excelPath: 'workbook.xlsx')" + example = "connection(action: 'list', presentationPath: 'presentation.pptx')" } }, usage = new { - discovery = "Use tool 'list' actions to discover workbook contents", + discovery = "Use tool 'list' actions to discover presentation contents", inspection = "Use tool 'view' actions to examine specific items", modification = "Use other tool actions to create/update/delete items" }, - futureEnhancements = "Dynamic resource templates (excel://{path}/queries/{name}) will be added when MCP SDK supports McpServerResourceTemplate" + futureEnhancements = "Dynamic resource templates (ppt://{path}/slides/{name}) will be added when MCP SDK supports McpServerResourceTemplate" }; return Task.FromResult(JsonSerializer.Serialize(guide, JsonOptions)); } /// - /// Quick reference for common Excel operations. + /// Quick reference for common PowerPoint operations. /// - [McpServerResource(UriTemplate = "excel://help/quickref")] - [Description("Quick reference for common Excel MCP operations")] + [McpServerResource(UriTemplate = "ppt://help/quickref")] + [Description("Quick reference for common PowerPoint MCP operations")] public static Task GetQuickReference() { var quickRef = new { - title = "Excel MCP Quick Reference", + title = "PowerPoint MCP Quick Reference", commonOperations = new[] { new @@ -113,42 +113,42 @@ public static Task GetQuickReference() task = "List all Power Queries", tool = "powerquery", action = "list", - example = "powerquery(action: 'list', excelPath: 'workbook.xlsx')" + example = "powerquery(action: 'list', presentationPath: 'presentation.pptx')" }, new { task = "View Power Query M code", tool = "powerquery", action = "view", - example = "powerquery(action: 'view', excelPath: 'workbook.xlsx', queryName: 'SalesData')" + example = "powerquery(action: 'view', presentationPath: 'presentation.pptx', queryName: 'SalesData')" }, new { task = "Import query to Data Model", tool = "powerquery", action = "import", - example = "powerquery(action: 'import', excelPath: 'workbook.xlsx', queryName: 'Sales', sourcePath: 'sales.pq', loadDestination: 'data-model')" + example = "powerquery(action: 'import', presentationPath: 'presentation.pptx', queryName: 'Sales', sourcePath: 'sales.pq', loadDestination: 'data-model')" }, new { - task = "List all worksheets", - tool = "worksheet", + task = "List all slides", + tool = "slide", action = "list", - example = "worksheet(action: 'list', excelPath: 'workbook.xlsx')" + example = "slide(action: 'list', presentationPath: 'presentation.pptx')" }, new { task = "List all DAX measures", tool = "datamodel", action = "list-measures", - example = "datamodel(action: 'list-measures', excelPath: 'workbook.xlsx')" + example = "datamodel(action: 'list-measures', presentationPath: 'presentation.pptx')" }, new { task = "Get cell values", tool = "range", action = "get-values", - example = "range(action: 'get-values', excelPath: 'workbook.xlsx', sheetName: 'Data', rangeAddress: 'A1:D10')" + example = "range(action: 'get-values', presentationPath: 'presentation.pptx', sheetName: 'Data', rangeAddress: 'A1:D10')" }, new { @@ -160,7 +160,7 @@ public static Task GetQuickReference() }, sessionWorkflow = new[] { - "Open session: file(action: 'open', excelPath: '...')", + "Open session: file(action: 'open', presentationPath: '...')", "Use sessionId with all subsequent operations", "Close session: file(action: 'close', sessionId: '...', save: true)" } diff --git a/src/ExcelMcp.McpServer/ServiceBridge/ServiceBridge.cs b/src/PptMcp.McpServer/ServiceBridge/ServiceBridge.cs similarity index 87% rename from src/ExcelMcp.McpServer/ServiceBridge/ServiceBridge.cs rename to src/PptMcp.McpServer/ServiceBridge/ServiceBridge.cs index 0c2b8b3c..67452cc6 100644 --- a/src/ExcelMcp.McpServer/ServiceBridge/ServiceBridge.cs +++ b/src/PptMcp.McpServer/ServiceBridge/ServiceBridge.cs @@ -1,16 +1,16 @@ using System.Text.Json; -using Sbroenne.ExcelMcp.Service; +using PptMcp.Service; -namespace Sbroenne.ExcelMcp.McpServer.ServiceBridge; +namespace PptMcp.McpServer.ServiceBridge; /// -/// Bridge that holds the in-process ExcelMCP Service for direct method calls. +/// Bridge that holds the in-process PptMcp Service for direct method calls. /// No named pipe — MCP tools call the service directly (same process). /// public static class ServiceBridge { private static readonly SemaphoreSlim _initLock = new(1, 1); - private static Service.ExcelMcpService? _service; + private static Service.PptMcpService? _service; /// /// JSON serializer options for deserializing service responses. @@ -18,7 +18,7 @@ public static class ServiceBridge public static readonly JsonSerializerOptions JsonOptions = ServiceProtocol.JsonOptions; /// - /// Ensures the in-process ExcelMCP Service is created. + /// Ensures the in-process PptMcp Service is created. /// Called automatically on first request. /// public static async Task EnsureServiceAsync(CancellationToken cancellationToken = default) @@ -36,7 +36,7 @@ public static async Task EnsureServiceAsync(CancellationToken cancellation return true; } - _service = new Service.ExcelMcpService(); + _service = new Service.PptMcpService(); return true; } catch (Exception) @@ -50,7 +50,7 @@ public static async Task EnsureServiceAsync(CancellationToken cancellation } /// - /// Sends a command to the ExcelMCP Service directly (in-process, no pipe). + /// Sends a command to the PptMcp Service directly (in-process, no pipe). /// public static async Task SendAsync( string command, @@ -64,7 +64,7 @@ public static async Task SendAsync( return new ServiceResponse { Success = false, - ErrorMessage = "Failed to start ExcelMCP Service in-process." + ErrorMessage = "Failed to start PptMcp Service in-process." }; } @@ -123,14 +123,14 @@ public static async Task WithSessionAsync( /// Opens a session via the service. /// public static async Task OpenSessionAsync( - string excelPath, + string presentationPath, bool show = false, int? timeoutSeconds = null, CancellationToken cancellationToken = default) { return await SendAsync("session.open", null, new { - filePath = excelPath, + filePath = presentationPath, show, timeoutSeconds }, timeoutSeconds, cancellationToken); @@ -140,7 +140,7 @@ public static async Task OpenSessionAsync( /// Creates a new file and opens a session via the service. /// public static async Task CreateSessionAsync( - string excelPath, + string presentationPath, bool macroEnabled = false, bool show = false, int? timeoutSeconds = null, @@ -148,7 +148,7 @@ public static async Task CreateSessionAsync( { return await SendAsync("session.create", null, new { - filePath = excelPath, + filePath = presentationPath, macroEnabled, show, timeoutSeconds @@ -188,14 +188,14 @@ public static async Task SaveSessionAsync( /// Tests if a file can be opened via the service. /// public static async Task TestFileAsync( - string excelPath, + string presentationPath, CancellationToken cancellationToken = default) { - return await SendAsync("session.test", null, new { filePath = excelPath }, cancellationToken: cancellationToken); + return await SendAsync("session.test", null, new { filePath = presentationPath }, cancellationToken: cancellationToken); } /// - /// Disposes the in-process ExcelMCP Service, auto-saving all sessions before shutdown. + /// Disposes the in-process PptMcp Service, auto-saving all sessions before shutdown. /// Must be called when the MCP server process exits to prevent silent data loss. /// public static void Dispose() diff --git a/src/PptMcp.McpServer/Tools/PptFileTool.cs b/src/PptMcp.McpServer/Tools/PptFileTool.cs new file mode 100644 index 00000000..380b071e --- /dev/null +++ b/src/PptMcp.McpServer/Tools/PptFileTool.cs @@ -0,0 +1,259 @@ +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Serialization; +using ModelContextProtocol.Server; +using PptMcp.Core.Commands.File; + +namespace PptMcp.McpServer.Tools; + +/// +/// Actions for the file tool (hand-coded because session management is not generated). +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum PptFileAction +{ + [JsonStringEnumMemberName("open")] Open, + [JsonStringEnumMemberName("close")] Close, + [JsonStringEnumMemberName("create")] Create, + [JsonStringEnumMemberName("list")] List, + [JsonStringEnumMemberName("test")] Test, + [JsonStringEnumMemberName("save")] Save +} + +/// +/// PowerPoint file and session management tool for MCP server. +/// +[McpServerToolType] +public static class PptFileTool +{ + /// + /// File and session management for PowerPoint automation. + /// + /// WORKFLOW: open → use session_id with other tools → close (save=true to persist changes). + /// NEW FILES: Use 'create' action to create file AND start session in one call. + /// + /// SESSION REUSE: Call 'list' first to check for existing sessions. + /// If file is already open, reuse existing session_id instead of opening again. + /// + [McpServerTool(Name = "file", Title = "File Operations", Destructive = true)] + [Description("File and session management for PowerPoint automation. WORKFLOW: open → use session_id with other tools → close (save=true to persist changes).")] + public static string PptFile( + PptFileAction action, + [DefaultValue(null)] string? path, + [DefaultValue(null)] string? session_id, + [DefaultValue(false)] bool save, + [DefaultValue(false)] bool show, + [DefaultValue(300)] int timeout_seconds) + { + return PptToolsBase.ExecuteToolAction("file", action.ToString().ToLowerInvariant(), path, () => + { + return action switch + { + PptFileAction.List => ListSessions(), + PptFileAction.Open => OpenSession(path!, show, timeout_seconds), + PptFileAction.Close => CloseSession(session_id!, save), + PptFileAction.Create => CreateSession(path!, show, timeout_seconds), + PptFileAction.Test => TestFile(path!), + PptFileAction.Save => SaveSession(session_id!), + _ => throw new ArgumentException($"Unknown action: {action}") + }; + }); + } + + private static string OpenSession(string path, bool show, int timeoutSeconds) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("path is required for 'open' action"); + + var pathError = PptToolsBase.ValidateWindowsPath(path); + if (pathError != null) return pathError; + + if (!File.Exists(path)) + { + return JsonSerializer.Serialize(new + { + success = false, + errorMessage = $"File not found: {path}", + filePath = path, + isError = true + }, PptToolsBase.JsonOptions); + } + + var response = ServiceBridge.ServiceBridge.SendAsync( + "session.open", null, + new { filePath = path, show, timeoutSeconds }, + timeoutSeconds + ).GetAwaiter().GetResult(); + + if (!response.Success) + { + return JsonSerializer.Serialize(new + { + success = false, + errorMessage = response.ErrorMessage ?? "Failed to open session", + filePath = path, + isError = true + }, PptToolsBase.JsonOptions); + } + + return TransformSessionResponse(response.Result, path); + } + + private static string CloseSession(string sessionId, bool save) + { + if (string.IsNullOrWhiteSpace(sessionId)) + throw new ArgumentException("session_id is required for 'close' action"); + + var response = ServiceBridge.ServiceBridge.SendAsync( + "session.close", sessionId, new { save } + ).GetAwaiter().GetResult(); + + if (!response.Success) + { + return JsonSerializer.Serialize(new + { + success = false, + session_id = sessionId, + errorMessage = response.ErrorMessage ?? "Failed to close session", + isError = true + }, PptToolsBase.JsonOptions); + } + + return response.Result ?? JsonSerializer.Serialize(new + { + success = true, + session_id = sessionId, + saved = save + }, PptToolsBase.JsonOptions); + } + + private static string CreateSession(string path, bool show, int timeoutSeconds) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("path is required for 'create' action"); + + var pathError = PptToolsBase.ValidateWindowsPath(path); + if (pathError != null) return pathError; + + var response = ServiceBridge.ServiceBridge.SendAsync( + "session.create", null, + new { filePath = path, show, timeoutSeconds }, + timeoutSeconds + ).GetAwaiter().GetResult(); + + if (!response.Success) + { + return JsonSerializer.Serialize(new + { + success = false, + errorMessage = response.ErrorMessage ?? "Failed to create session", + filePath = path, + isError = true + }, PptToolsBase.JsonOptions); + } + + return TransformSessionResponse(response.Result, path); + } + + private static string SaveSession(string sessionId) + { + if (string.IsNullOrWhiteSpace(sessionId)) + throw new ArgumentException("session_id is required for 'save' action"); + + var response = ServiceBridge.ServiceBridge.SendAsync( + "session.save", sessionId + ).GetAwaiter().GetResult(); + + if (!response.Success) + { + return JsonSerializer.Serialize(new + { + success = false, + session_id = sessionId, + errorMessage = response.ErrorMessage ?? "Failed to save", + isError = true + }, PptToolsBase.JsonOptions); + } + + return JsonSerializer.Serialize(new + { + success = true, + session_id = sessionId + }, PptToolsBase.JsonOptions); + } + + private static string ListSessions() + { + var response = ServiceBridge.ServiceBridge.SendAsync("session.list").GetAwaiter().GetResult(); + + if (!response.Success) + { + return JsonSerializer.Serialize(new + { + success = false, + errorMessage = response.ErrorMessage ?? "Failed to list sessions", + isError = true + }, PptToolsBase.JsonOptions); + } + + return response.Result ?? JsonSerializer.Serialize(new + { + success = true, + sessions = Array.Empty(), + count = 0 + }, PptToolsBase.JsonOptions); + } + + private static string TestFile(string path) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("path is required for 'test' action"); + + var pathError = PptToolsBase.ValidateWindowsPath(path); + if (pathError != null) return pathError; + + var fileCommands = new FileCommands(); + var info = fileCommands.Test(path); + + return JsonSerializer.Serialize(new + { + success = info.Success, + exists = info.Exists, + filePath = info.FilePath, + fileName = info.FileName, + fileSizeBytes = info.FileSizeBytes, + isReadOnly = info.IsReadOnly, + isMacroEnabled = info.IsMacroEnabled + }, PptToolsBase.JsonOptions); + } + + /// + /// Transforms the service response to use snake_case session_id for MCP compatibility. + /// + private static string TransformSessionResponse(string? result, string path) + { + if (!string.IsNullOrEmpty(result)) + { + try + { + using var doc = JsonDocument.Parse(result); + if (doc.RootElement.TryGetProperty("sessionId", out var sessionIdProp)) + { + var sessionId = sessionIdProp.GetString(); + string? filePath = doc.RootElement.TryGetProperty("filePath", out var fp) ? fp.GetString() : path; + return JsonSerializer.Serialize(new + { + success = true, + session_id = sessionId, + filePath + }, PptToolsBase.JsonOptions); + } + } + catch (JsonException) { } + return result; + } + + return JsonSerializer.Serialize(new { success = true, filePath = path }, PptToolsBase.JsonOptions); + } +} + diff --git a/src/ExcelMcp.McpServer/Tools/ExcelTools.cs b/src/PptMcp.McpServer/Tools/PptTools.cs similarity index 77% rename from src/ExcelMcp.McpServer/Tools/ExcelTools.cs rename to src/PptMcp.McpServer/Tools/PptTools.cs index f9593837..44a471f0 100644 --- a/src/ExcelMcp.McpServer/Tools/ExcelTools.cs +++ b/src/PptMcp.McpServer/Tools/PptTools.cs @@ -1,17 +1,17 @@ #pragma warning disable IL2070 // 'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' requirements -namespace Sbroenne.ExcelMcp.McpServer.Tools; +namespace PptMcp.McpServer.Tools; /// -/// Excel tools documentation and guidance for Model Context Protocol (MCP) server. +/// PowerPoint tools documentation and guidance for Model Context Protocol (MCP) server. /// /// 📝 Parameter Patterns: /// - action: Always the first parameter, defines what operation to perform -/// - filePath/path: Excel file path (.xlsx or .xlsm based on requirements) +/// - filePath/path: PowerPoint file path (.pptx based on requirements) /// - Context-specific parameters: Each tool has domain-appropriate parameters /// /// 🎯 Design Philosophy: -/// - Resource-based: Tools represent Excel domains, not individual operations +/// - Resource-based: Tools represent PowerPoint domains, not individual operations /// - Action-oriented: Each tool supports multiple related actions /// - LLM-friendly: Clear naming, comprehensive documentation, predictable patterns /// - Error-consistent: Standardized error handling across all tools @@ -21,7 +21,7 @@ namespace Sbroenne.ExcelMcp.McpServer.Tools; /// /// This prevents duplicate tool registration conflicts in the MCP framework. /// -public static class ExcelTools +public static class PptTools { // This class now serves as documentation only. // All MCP tool registrations have been moved to individual tool files diff --git a/src/ExcelMcp.McpServer/Tools/ExcelToolsBase.cs b/src/PptMcp.McpServer/Tools/PptToolsBase.cs similarity index 84% rename from src/ExcelMcp.McpServer/Tools/ExcelToolsBase.cs rename to src/PptMcp.McpServer/Tools/PptToolsBase.cs index 2a68a30c..dca954c1 100644 --- a/src/ExcelMcp.McpServer/Tools/ExcelToolsBase.cs +++ b/src/PptMcp.McpServer/Tools/PptToolsBase.cs @@ -1,24 +1,22 @@ -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; -using Sbroenne.ExcelMcp.McpServer.Telemetry; #pragma warning disable IL2070 // 'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' requirements -namespace Sbroenne.ExcelMcp.McpServer.Tools; +namespace PptMcp.McpServer.Tools; /// -/// Base class for Excel MCP tools providing common patterns and utilities. -/// All Excel tools inherit from this to ensure consistency for LLM usage. +/// Base class for PowerPoint MCP tools providing common patterns and utilities. +/// All PowerPoint tools inherit from this to ensure consistency for LLM usage. /// -/// The MCP Server forwards ALL requests to the in-process ExcelMCP Service. +/// The MCP Server forwards ALL requests to the in-process PptMcp Service. /// [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] -public static class ExcelToolsBase +public static class PptToolsBase { /// - /// Ensures the ExcelMCP Service is running. + /// Ensures the PptMcp Service is running. /// The service is required for all MCP Server operations. /// public static async Task EnsureServiceAsync(CancellationToken cancellationToken = default) @@ -53,7 +51,7 @@ public static async Task EnsureServiceAsync(CancellationToken cancellation (command, sessionId, args) => ForwardToService(command, sessionId, args); /// - /// Forwards a command to the ExcelMCP Service and returns the JSON response. + /// Forwards a command to the PptMcp Service and returns the JSON response. /// This is the primary method for MCP tools to execute commands. /// /// The command format is "category.action", e.g., "sheet.list", "range.get-values". @@ -89,7 +87,7 @@ public static string ForwardToService( } /// - /// Forwards a command to the ExcelMCP Service without a session. + /// Forwards a command to the PptMcp Service without a session. /// Used for commands that don't require an active session (e.g., service.status). /// public static string ForwardToServiceNoSession( @@ -117,9 +115,8 @@ public static string ForwardToServiceNoSession( /// /// Executes a tool operation and serializes any exception using shared error formatting. - /// Tracks tool usage telemetry (if enabled). /// - /// Tool name for telemetry (e.g., "range"). + /// Tool name (e.g., "range"). /// Action string (kebab-case) included in error context. /// Synchronous operation to execute. /// Optional handler that can override default error serialization. Return null/empty to fall back to default. @@ -133,11 +130,10 @@ public static string ExecuteToolAction( /// /// Executes a tool operation and serializes any exception using shared error formatting. - /// Tracks tool usage telemetry (if enabled). /// - /// Tool name for telemetry (e.g., "range"). + /// Tool name (e.g., "range"). /// Action string (kebab-case) included in error context. - /// Optional Excel path for context in error messages. + /// Optional PowerPoint path for context in error messages. /// Synchronous operation to execute. /// Optional handler that can override default error serialization. Return null/empty to fall back to default. /// Serialized JSON response. @@ -148,29 +144,24 @@ public static string ExecuteToolAction( Func operation, Func? customHandler = null) { - var stopwatch = Stopwatch.StartNew(); - var success = false; - try { - var result = operation(); - success = true; - return result; + return operation(); } catch (Exception ex) { // Log COM exceptions to stderr for diagnostic capture if (ex is System.Runtime.InteropServices.COMException comEx) { - Console.Error.WriteLine($"[ExcelMcp] COM Exception in {toolName}/{actionName}: HResult=0x{comEx.HResult:X8}, Message={comEx.Message}"); + Console.Error.WriteLine($"[PptMcp] COM Exception in {toolName}/{actionName}: HResult=0x{comEx.HResult:X8}, Message={comEx.Message}"); if (ex.StackTrace != null) { - Console.Error.WriteLine($"[ExcelMcp] StackTrace: {ex.StackTrace[..Math.Min(500, ex.StackTrace.Length)]}"); + Console.Error.WriteLine($"[PptMcp] StackTrace: {ex.StackTrace[..Math.Min(500, ex.StackTrace.Length)]}"); } } else if (ex.InnerException is System.Runtime.InteropServices.COMException innerComEx) { - Console.Error.WriteLine($"[ExcelMcp] Inner COM Exception in {toolName}/{actionName}: HResult=0x{innerComEx.HResult:X8}, Message={innerComEx.Message}"); + Console.Error.WriteLine($"[PptMcp] Inner COM Exception in {toolName}/{actionName}: HResult=0x{innerComEx.HResult:X8}, Message={innerComEx.Message}"); } if (customHandler != null) @@ -184,11 +175,6 @@ public static string ExecuteToolAction( return SerializeToolError(actionName, path, ex); } - finally - { - stopwatch.Stop(); - ExcelMcpTelemetry.TrackToolInvocation(toolName, actionName, stopwatch.ElapsedMilliseconds, success, path); - } } /// @@ -205,14 +191,14 @@ public static string ExecuteToolAction( } // Use .NET's built-in check for fully qualified Windows paths - // Returns false for Unix paths like /home/user/file.xlsx, relative paths like ./file.xlsx + // Returns false for Unix paths like /home/user/file.pptx, relative paths like ./file.pptx if (!Path.IsPathFullyQualified(path)) { // Extract filename from the invalid path (works for both Unix and Windows separators) var fileName = Path.GetFileName(path.Replace('/', Path.DirectorySeparatorChar)); if (string.IsNullOrEmpty(fileName)) { - fileName = "workbook.xlsx"; + fileName = "presentation.pptx"; } // Get user's actual Documents folder to provide a valid suggestion @@ -243,7 +229,7 @@ public static string ExecuteToolAction( /// Includes detailed COM exception info for diagnostics. /// /// Action string (kebab-case) included in message. - /// Optional Excel path context. + /// Optional PowerPoint path context. /// Exception to serialize. /// Serialized JSON error payload. public static string SerializeToolError(string actionName, string? path, Exception ex) diff --git a/src/ExcelMcp.Service/ExcelMcp.Service.csproj b/src/PptMcp.Service/PptMcp.Service.csproj similarity index 62% rename from src/ExcelMcp.Service/ExcelMcp.Service.csproj rename to src/PptMcp.Service/PptMcp.Service.csproj index 78c9ee5f..f02083ac 100644 --- a/src/ExcelMcp.Service/ExcelMcp.Service.csproj +++ b/src/PptMcp.Service/PptMcp.Service.csproj @@ -1,14 +1,14 @@ - net10.0-windows + net9.0-windows enable enable - Sbroenne.ExcelMcp.Service - Sbroenne.ExcelMcp.Service + PptMcp.Service + PptMcp.Service $(NoWarn);CS1591 @@ -30,16 +30,16 @@ - - + + - - - - + + + + diff --git a/src/ExcelMcp.Service/ExcelMcpService.cs b/src/PptMcp.Service/PptMcpService.cs similarity index 60% rename from src/ExcelMcp.Service/ExcelMcpService.cs rename to src/PptMcp.Service/PptMcpService.cs index ca0883d4..f2f68df6 100644 --- a/src/ExcelMcp.Service/ExcelMcpService.cs +++ b/src/PptMcp.Service/PptMcpService.cs @@ -1,30 +1,42 @@ using System.IO.Pipes; using System.Runtime.InteropServices; using System.Text.Json; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands; -using Sbroenne.ExcelMcp.Core.Commands.Calculation; -using Sbroenne.ExcelMcp.Core.Commands.Chart; -using Sbroenne.ExcelMcp.Core.Commands.Diag; -using Sbroenne.ExcelMcp.Core.Commands.PivotTable; -using Sbroenne.ExcelMcp.Core.Commands.Range; -using Sbroenne.ExcelMcp.Service.Rpc; +using PptMcp.ComInterop.Session; +using PptMcp.Core.Commands.Accessibility; +using PptMcp.Core.Commands.Animation; +using PptMcp.Core.Commands.Chart; +using PptMcp.Core.Commands.Design; +using PptMcp.Core.Commands.DocumentProperty; +using PptMcp.Core.Commands.Export; +using PptMcp.Core.Commands.File; +using PptMcp.Core.Commands.Hyperlink; +using PptMcp.Core.Commands.Image; +using PptMcp.Core.Commands.Master; +using PptMcp.Core.Commands.Media; +using PptMcp.Core.Commands.Notes; +using PptMcp.Core.Commands.Proofing; +using PptMcp.Core.Commands.Section; +using PptMcp.Core.Commands.Shape; +using PptMcp.Core.Commands.Slide; +using PptMcp.Core.Commands.SlideTable; +using PptMcp.Core.Commands.Slideshow; +using PptMcp.Core.Commands.Text; +using PptMcp.Core.Commands.Transition; +using PptMcp.Core.Commands.Vba; +using PptMcp.Core.Commands.Window; +using PptMcp.Service.Rpc; using StreamJsonRpc; -using Sbroenne.ExcelMcp.Core.Commands.Screenshot; -using Sbroenne.ExcelMcp.Core.Commands.Slicer; -using Sbroenne.ExcelMcp.Core.Commands.Table; -using Sbroenne.ExcelMcp.Core.Commands.Window; -using Sbroenne.ExcelMcp.Generated; +using PptMcp.Generated; -namespace Sbroenne.ExcelMcp.Service; +namespace PptMcp.Service; /// -/// The ExcelMCP Service. Holds SessionManager and executes Core commands. +/// The PptMcp Service. Holds SessionManager and executes Core commands. /// Runs in-process within the host (MCP Server or CLI), accepting commands via named pipe. /// The named pipe enables cross-thread communication between the host's request threads /// and the service's STA thread (required for COM interop). /// -public sealed class ExcelMcpService : IDisposable +public sealed class PptMcpService : IDisposable { private readonly SessionManager _sessionManager = new(); private readonly CancellationTokenSource _shutdownCts = new(); @@ -34,27 +46,32 @@ public sealed class ExcelMcpService : IDisposable private DateTime _lastActivityTime = DateTime.UtcNow; private bool _disposed; - // Core command instances - use concrete types per CA1859 - private readonly RangeCommands _rangeCommands = new(); - private readonly SheetCommands _sheetCommands = new(); - private readonly TableCommands _tableCommands = new(); - private readonly PowerQueryCommands _powerQueryCommands; - private readonly PivotTableCommands _pivotTableCommands = new(); - private readonly SlicerCommands _slicerCommands = new(); + // Core command instances + private readonly FileCommands _fileCommands = new(); + private readonly SlideCommands _slideCommands = new(); + private readonly ShapeCommands _shapeCommands = new(); + private readonly TextCommands _textCommands = new(); + private readonly NotesCommands _notesCommands = new(); + private readonly MasterCommands _masterCommands = new(); + private readonly ExportCommands _exportCommands = new(); + private readonly TransitionCommands _transitionCommands = new(); + private readonly ImageCommands _imageCommands = new(); + private readonly SlideTableCommands _slideTableCommands = new(); private readonly ChartCommands _chartCommands = new(); - private readonly ConnectionCommands _connectionCommands = new(); - private readonly NamedRangeCommands _namedRangeCommands = new(); - private readonly ConditionalFormattingCommands _conditionalFormatCommands = new(); + private readonly AnimationCommands _animationCommands = new(); + private readonly DesignCommands _designCommands = new(); + private readonly SlideshowCommands _slideshowCommands = new(); private readonly VbaCommands _vbaCommands = new(); - private readonly DataModelCommands _dataModelCommands = new(); - private readonly CalculationModeCommands _calculationModeCommands = new(); - private readonly ScreenshotCommands _screenshotCommands = new(); - private readonly DiagCommands _diagCommands = new(); private readonly WindowCommands _windowCommands = new(); - - public ExcelMcpService() + private readonly HyperlinkCommands _hyperlinkCommands = new(); + private readonly SectionCommands _sectionCommands = new(); + private readonly DocumentPropertyCommands _documentPropertyCommands = new(); + private readonly MediaCommands _mediaCommands = new(); + private readonly ProofingCommands _proofingCommands = new(); + private readonly AccessibilityCommands _accessibilityCommands = new(); + + public PptMcpService() { - _powerQueryCommands = new PowerQueryCommands(_dataModelCommands); } public DateTime StartTime => _startTime; @@ -198,56 +215,71 @@ public async Task ProcessAsync(ServiceRequest request) { "service" => HandleServiceCommand(action), "session" => HandleSessionCommand(action, request), - "sheet" or "sheetstyle" => await DispatchSheetAsync(action, request), - "range" or "rangeedit" or "rangeformat" or "rangelink" => await DispatchRangeAsync(action, request), - "table" or "tablecolumn" => await DispatchTableAsync(action, request), - "powerquery" => await DispatchSimpleAsync(action, request, - ServiceRegistry.PowerQuery.TryParseAction, - (a, batch) => ServiceRegistry.PowerQuery.DispatchToCore(_powerQueryCommands, a, batch, request.Args)), - "pivottable" => await DispatchSimpleAsync(action, request, - ServiceRegistry.PivotTable.TryParseAction, - (a, batch) => ServiceRegistry.PivotTable.DispatchToCore(_pivotTableCommands, a, batch, request.Args)), - "pivottablefield" => await DispatchSimpleAsync(action, request, - ServiceRegistry.PivotTableField.TryParseAction, - (a, batch) => ServiceRegistry.PivotTableField.DispatchToCore(_pivotTableCommands, a, batch, request.Args)), - "pivottablecalc" => await DispatchSimpleAsync(action, request, - ServiceRegistry.PivotTableCalc.TryParseAction, - (a, batch) => ServiceRegistry.PivotTableCalc.DispatchToCore(_pivotTableCommands, a, batch, request.Args)), + "diag" => HandleDiagCommand(action, request), + "file" => DispatchSessionless(action, request), + "slide" => await DispatchSimpleAsync(action, request, + ServiceRegistry.Slide.TryParseAction, + (a, batch) => ServiceRegistry.Slide.DispatchToCore(_slideCommands, a, batch, request.Args)), + "shape" => await DispatchSimpleAsync(action, request, + ServiceRegistry.Shape.TryParseAction, + (a, batch) => ServiceRegistry.Shape.DispatchToCore(_shapeCommands, a, batch, request.Args)), + "text" => await DispatchSimpleAsync(action, request, + ServiceRegistry.Text.TryParseAction, + (a, batch) => ServiceRegistry.Text.DispatchToCore(_textCommands, a, batch, request.Args)), + "notes" => await DispatchSimpleAsync(action, request, + ServiceRegistry.Notes.TryParseAction, + (a, batch) => ServiceRegistry.Notes.DispatchToCore(_notesCommands, a, batch, request.Args)), + "master" => await DispatchSimpleAsync(action, request, + ServiceRegistry.Master.TryParseAction, + (a, batch) => ServiceRegistry.Master.DispatchToCore(_masterCommands, a, batch, request.Args)), + "export" => await DispatchSimpleAsync(action, request, + ServiceRegistry.Export.TryParseAction, + (a, batch) => ServiceRegistry.Export.DispatchToCore(_exportCommands, a, batch, request.Args)), + "transition" => await DispatchSimpleAsync(action, request, + ServiceRegistry.Transition.TryParseAction, + (a, batch) => ServiceRegistry.Transition.DispatchToCore(_transitionCommands, a, batch, request.Args)), + "image" => await DispatchSimpleAsync(action, request, + ServiceRegistry.Image.TryParseAction, + (a, batch) => ServiceRegistry.Image.DispatchToCore(_imageCommands, a, batch, request.Args)), + "slidetable" => await DispatchSimpleAsync(action, request, + ServiceRegistry.Slidetable.TryParseAction, + (a, batch) => ServiceRegistry.Slidetable.DispatchToCore(_slideTableCommands, a, batch, request.Args)), "chart" => await DispatchSimpleAsync(action, request, ServiceRegistry.Chart.TryParseAction, (a, batch) => ServiceRegistry.Chart.DispatchToCore(_chartCommands, a, batch, request.Args)), - "chartconfig" => await DispatchSimpleAsync(action, request, - ServiceRegistry.ChartConfig.TryParseAction, - (a, batch) => ServiceRegistry.ChartConfig.DispatchToCore(_chartCommands, a, batch, request.Args)), - "connection" => await DispatchSimpleAsync(action, request, - ServiceRegistry.Connection.TryParseAction, - (a, batch) => ServiceRegistry.Connection.DispatchToCore(_connectionCommands, a, batch, request.Args)), - "calculation" => await DispatchSimpleAsync(action, request, - ServiceRegistry.Calculation.TryParseAction, - (a, batch) => ServiceRegistry.Calculation.DispatchToCore(_calculationModeCommands, a, batch, request.Args)), - "namedrange" => await DispatchSimpleAsync(action, request, - ServiceRegistry.NamedRange.TryParseAction, - (a, batch) => ServiceRegistry.NamedRange.DispatchToCore(_namedRangeCommands, a, batch, request.Args)), - "conditionalformat" => await DispatchSimpleAsync(action, request, - ServiceRegistry.ConditionalFormat.TryParseAction, - (a, batch) => ServiceRegistry.ConditionalFormat.DispatchToCore(_conditionalFormatCommands, a, batch, request.Args)), + "animation" => await DispatchSimpleAsync(action, request, + ServiceRegistry.Animation.TryParseAction, + (a, batch) => ServiceRegistry.Animation.DispatchToCore(_animationCommands, a, batch, request.Args)), + "design" => await DispatchSimpleAsync(action, request, + ServiceRegistry.Design.TryParseAction, + (a, batch) => ServiceRegistry.Design.DispatchToCore(_designCommands, a, batch, request.Args)), + "slideshow" => await DispatchSimpleAsync(action, request, + ServiceRegistry.Slideshow.TryParseAction, + (a, batch) => ServiceRegistry.Slideshow.DispatchToCore(_slideshowCommands, a, batch, request.Args)), "vba" => await DispatchSimpleAsync(action, request, ServiceRegistry.Vba.TryParseAction, (a, batch) => ServiceRegistry.Vba.DispatchToCore(_vbaCommands, a, batch, request.Args)), - "datamodel" => await DispatchSimpleAsync(action, request, - ServiceRegistry.DataModel.TryParseAction, - (a, batch) => ServiceRegistry.DataModel.DispatchToCore(_dataModelCommands, a, batch, request.Args)), - "datamodelrel" => await DispatchSimpleAsync(action, request, - ServiceRegistry.DataModelRel.TryParseAction, - (a, batch) => ServiceRegistry.DataModelRel.DispatchToCore(_dataModelCommands, a, batch, request.Args)), - "slicer" => await DispatchSimpleAsync(action, request, - ServiceRegistry.Slicer.TryParseAction, - (a, batch) => ServiceRegistry.Slicer.DispatchToCore(_slicerCommands, a, batch, request.Args)), - "screenshot" => await DispatchSimpleAsync(action, request, - ServiceRegistry.Screenshot.TryParseAction, - (a, batch) => ServiceRegistry.Screenshot.DispatchToCore(_screenshotCommands, a, batch, request.Args)), - "window" => await DispatchWindowAsync(action, request), - "diag" => DispatchSessionless(action, request), + "window" => await DispatchSimpleAsync(action, request, + ServiceRegistry.Window.TryParseAction, + (a, batch) => ServiceRegistry.Window.DispatchToCore(_windowCommands, a, batch, request.Args)), + "hyperlink" => await DispatchSimpleAsync(action, request, + ServiceRegistry.Hyperlink.TryParseAction, + (a, batch) => ServiceRegistry.Hyperlink.DispatchToCore(_hyperlinkCommands, a, batch, request.Args)), + "section" => await DispatchSimpleAsync(action, request, + ServiceRegistry.Section.TryParseAction, + (a, batch) => ServiceRegistry.Section.DispatchToCore(_sectionCommands, a, batch, request.Args)), + "docproperty" => await DispatchSimpleAsync(action, request, + ServiceRegistry.Docproperty.TryParseAction, + (a, batch) => ServiceRegistry.Docproperty.DispatchToCore(_documentPropertyCommands, a, batch, request.Args)), + "media" => await DispatchSimpleAsync(action, request, + ServiceRegistry.Media.TryParseAction, + (a, batch) => ServiceRegistry.Media.DispatchToCore(_mediaCommands, a, batch, request.Args)), + "proofing" => await DispatchSimpleAsync(action, request, + ServiceRegistry.Proofing.TryParseAction, + (a, batch) => ServiceRegistry.Proofing.DispatchToCore(_proofingCommands, a, batch, request.Args)), + "accessibility" => await DispatchSimpleAsync(action, request, + ServiceRegistry.Accessibility.TryParseAction, + (a, batch) => ServiceRegistry.Accessibility.DispatchToCore(_accessibilityCommands, a, batch, request.Args)), _ => new ServiceResponse { Success = false, ErrorMessage = $"Unknown command category: {category}" } }; } @@ -291,6 +323,93 @@ private ServiceResponse HandleStatus() // === SESSION COMMANDS === + // === DIAG COMMANDS === + + private static ServiceResponse HandleDiagCommand(string action, ServiceRequest request) + { + return action switch + { + "ping" => new ServiceResponse + { + Success = true, + Result = JsonSerializer.Serialize(new + { + success = true, + action = "ping", + message = "pong", + timestamp = DateTime.UtcNow.ToString("o") + }, ServiceProtocol.JsonOptions) + }, + "echo" => HandleDiagEcho(request), + "validate-params" => HandleDiagValidateParams(request), + _ => new ServiceResponse { Success = false, ErrorMessage = $"Unknown diag action: {action}" } + }; + } + + private static ServiceResponse HandleDiagEcho(ServiceRequest request) + { + Dictionary? args = null; + if (!string.IsNullOrEmpty(request.Args)) + args = JsonSerializer.Deserialize>(request.Args, ServiceProtocol.JsonOptions); + + if (args == null || !args.TryGetValue("message", out var messageEl) || messageEl.ValueKind == JsonValueKind.Null) + { + return new ServiceResponse { Success = false, ErrorMessage = "Parameter 'message' is required for echo" }; + } + + var message = messageEl.GetString()!; + string? tag = null; + if (args.TryGetValue("tag", out var tagEl) && tagEl.ValueKind != JsonValueKind.Null) + tag = tagEl.GetString(); + + return new ServiceResponse + { + Success = true, + Result = JsonSerializer.Serialize(new + { + success = true, + action = "echo", + message, + tag + }, ServiceProtocol.JsonOptions) + }; + } + + private static ServiceResponse HandleDiagValidateParams(ServiceRequest request) + { + Dictionary? args = null; + if (!string.IsNullOrEmpty(request.Args)) + args = JsonSerializer.Deserialize>(request.Args, ServiceProtocol.JsonOptions); + + if (args == null || !args.TryGetValue("name", out var nameEl) || nameEl.ValueKind == JsonValueKind.Null) + { + return new ServiceResponse { Success = false, ErrorMessage = "Parameter 'name' is required for validate-params" }; + } + + var count = args.TryGetValue("count", out var countEl) && countEl.ValueKind == JsonValueKind.Number ? countEl.GetInt32() : 0; + string? label = args.TryGetValue("label", out var labelEl) && labelEl.ValueKind != JsonValueKind.Null ? labelEl.GetString() : null; + var verbose = args.TryGetValue("verbose", out var verboseEl) && verboseEl.ValueKind != JsonValueKind.Null && verboseEl.GetBoolean(); + + return new ServiceResponse + { + Success = true, + Result = JsonSerializer.Serialize(new + { + success = true, + action = "validate-params", + parameters = new + { + name = nameEl.GetString(), + count, + label, + verbose + } + }, ServiceProtocol.JsonOptions) + }; + } + + // === SESSION COMMANDS === + private ServiceResponse HandleSessionCommand(string action, ServiceRequest request) { return action switch @@ -319,24 +438,24 @@ private ServiceResponse HandleSessionCreate(ServiceRequest request) return new ServiceResponse { Success = false, - ErrorMessage = $"File already exists: {fullPath}. Use session open to open an existing workbook." + ErrorMessage = $"File already exists: {fullPath}. Use session open to open an existing presentation." }; } var extension = Path.GetExtension(fullPath); - if (!string.Equals(extension, ".xlsx", StringComparison.OrdinalIgnoreCase) - && !string.Equals(extension, ".xlsm", StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(extension, ".pptx", StringComparison.OrdinalIgnoreCase) + && !string.Equals(extension, ".pptm", StringComparison.OrdinalIgnoreCase)) { return new ServiceResponse { Success = false, - ErrorMessage = $"Invalid file extension '{extension}'. session create supports .xlsx and .xlsm only." + ErrorMessage = $"Invalid file extension '{extension}'. session create supports .pptx and .pptm only." }; } try { - // Use the combined create+open which starts Excel only once + // Use the combined create+open which starts PowerPoint only once TimeSpan? timeout = args.TimeoutSeconds.HasValue ? TimeSpan.FromSeconds(args.TimeoutSeconds.Value) : null; @@ -408,14 +527,14 @@ private ServiceResponse HandleSessionSave(ServiceRequest request) return new ServiceResponse { Success = false, ErrorMessage = $"Session '{request.SessionId}' not found" }; } - // Check if Excel process is still alive before attempting save - if (!batch.IsExcelProcessAlive()) + // Check if PowerPoint process is still alive before attempting save + if (!batch.IsPowerPointProcessAlive()) { _sessionManager.CloseSession(request.SessionId, save: false, force: true); return new ServiceResponse { Success = false, - ErrorMessage = $"Excel process for session '{request.SessionId}' has died. Session has been closed. Please create a new session." + ErrorMessage = $"PowerPoint process for session '{request.SessionId}' has died. Session has been closed. Please create a new session." }; } @@ -430,7 +549,7 @@ private ServiceResponse HandleSessionList() { sessionId = s.SessionId, filePath = s.FilePath, - isExcelVisible = _sessionManager.IsExcelVisible(s.SessionId), + isPowerPointVisible = _sessionManager.IsPowerPointVisible(s.SessionId), activeOperations = _sessionManager.GetActiveOperationCount(s.SessionId), canClose = _sessionManager.GetActiveOperationCount(s.SessionId) == 0 }) @@ -477,7 +596,7 @@ private async Task DispatchSimpleAsync( TryParseDelegate tryParse, - Func dispatch) where TAction : struct + Func dispatch) where TAction : struct { @@ -492,161 +611,18 @@ private async Task DispatchSimpleAsync( } /// - /// Dispatches a session-less command (no Excel batch required). - /// Used for [NoSession] categories like diag. + /// Dispatches a session-less command (no PowerPoint batch required). + /// Used for [NoSession] categories like file. /// private ServiceResponse DispatchSessionless(string actionString, ServiceRequest request) { - if (!ServiceRegistry.Diag.TryParseAction(actionString, out var action)) + if (!ServiceRegistry.File.TryParseAction(actionString, out var action)) return new ServiceResponse { Success = false, ErrorMessage = $"Unknown action: {actionString}" }; - return WrapResult(ServiceRegistry.Diag.DispatchToCore(_diagCommands, action, request.Args)); - } - - private async Task DispatchSheetAsync(string actionString, ServiceRequest request) - - { - - if (ServiceRegistry.Sheet.TryParseAction(actionString, out var sheetAction)) - - { - - // CopyToFile/MoveToFile are atomic operations without session - - if (sheetAction is SheetAction.CopyToFile or SheetAction.MoveToFile) - - { - - try - - { - - return WrapResult(ServiceRegistry.Sheet.DispatchToCore( - - _sheetCommands, sheetAction, null!, request.Args)); - - } - - catch (Exception ex) - - { - - return new ServiceResponse { Success = false, ErrorMessage = $"{ex.GetType().Name}: {ex.Message}" }; - - } - - } - - - - return await WithSessionAsync(request.SessionId, batch => - - WrapResult(ServiceRegistry.Sheet.DispatchToCore(_sheetCommands, sheetAction, batch, request.Args))); - - } - - - - if (ServiceRegistry.SheetStyle.TryParseAction(actionString, out var styleAction)) - - { - - return await WithSessionAsync(request.SessionId, batch => - - WrapResult(ServiceRegistry.SheetStyle.DispatchToCore(_sheetCommands, styleAction, batch, request.Args))); - - } - - - - return new ServiceResponse { Success = false, ErrorMessage = $"Unknown sheet action: {actionString}" }; - - } - - - - private async Task DispatchRangeAsync(string actionString, ServiceRequest request) - - { - - return await WithSessionAsync(request.SessionId, batch => - - { - - if (ServiceRegistry.Range.TryParseAction(actionString, out var ra)) - - return WrapResult(ServiceRegistry.Range.DispatchToCore(_rangeCommands, ra, batch, request.Args)); - - if (ServiceRegistry.RangeEdit.TryParseAction(actionString, out var rea)) - - return WrapResult(ServiceRegistry.RangeEdit.DispatchToCore(_rangeCommands, rea, batch, request.Args)); - - if (ServiceRegistry.RangeFormat.TryParseAction(actionString, out var rfa)) - - return WrapResult(ServiceRegistry.RangeFormat.DispatchToCore(_rangeCommands, rfa, batch, request.Args)); - - if (ServiceRegistry.RangeLink.TryParseAction(actionString, out var rla)) - - return WrapResult(ServiceRegistry.RangeLink.DispatchToCore(_rangeCommands, rla, batch, request.Args)); - - return new ServiceResponse { Success = false, ErrorMessage = $"Unknown range action: {actionString}" }; - - }); - - } - - - - private async Task DispatchTableAsync(string actionString, ServiceRequest request) - - { - - return await WithSessionAsync(request.SessionId, batch => - - { - - if (ServiceRegistry.Table.TryParseAction(actionString, out var ta)) - - return WrapResult(ServiceRegistry.Table.DispatchToCore(_tableCommands, ta, batch, request.Args)); - - if (ServiceRegistry.TableColumn.TryParseAction(actionString, out var tca)) - - return WrapResult(ServiceRegistry.TableColumn.DispatchToCore(_tableCommands, tca, batch, request.Args)); - - return new ServiceResponse { Success = false, ErrorMessage = $"Unknown table action: {actionString}" }; - - }); - + return WrapResult(ServiceRegistry.File.DispatchToCore(_fileCommands, action, request.Args)); } - private async Task DispatchWindowAsync(string actionString, ServiceRequest request) - { - if (!ServiceRegistry.Window.TryParseAction(actionString, out var windowAction)) - return new ServiceResponse { Success = false, ErrorMessage = $"Unknown window action: {actionString}" }; - - return await WithSessionAsync(request.SessionId, batch => - { - var result = WrapResult(ServiceRegistry.Window.DispatchToCore(_windowCommands, windowAction, batch, request.Args)); - - // Update SessionManager visibility flag when show/hide commands succeed - if (result.Success && !string.IsNullOrWhiteSpace(request.SessionId)) - { - if (windowAction is WindowAction.Show or WindowAction.Arrange or WindowAction.SetState or WindowAction.SetPosition) - { - _sessionManager.SetExcelVisible(request.SessionId, true); - } - else if (windowAction is WindowAction.Hide) - { - _sessionManager.SetExcelVisible(request.SessionId, false); - } - } - - return result; - }); - } - - - private Task WithSessionAsync(string? sessionId, Func action) + private Task WithSessionAsync(string? sessionId, Func action) { if (string.IsNullOrWhiteSpace(sessionId)) { @@ -659,15 +635,15 @@ private Task WithSessionAsync(string? sessionId, Func WithSessionAsync(string? sessionId, Func WithSessionAsync(string? sessionId, Func WithSessionAsync(string? sessionId, Func -/// Server-side RPC target that delegates incoming JSON-RPC calls to . +/// Server-side RPC target that delegates incoming JSON-RPC calls to . /// One instance is attached per pipe connection via JsonRpc.Attach(stream, target). /// -internal sealed class DaemonRpcTarget : IExcelDaemonRpc +internal sealed class DaemonRpcTarget : IPptDaemonRpc { - private readonly ExcelMcpService _service; + private readonly PptMcpService _service; - public DaemonRpcTarget(ExcelMcpService service) + public DaemonRpcTarget(PptMcpService service) { _service = service; } diff --git a/src/ExcelMcp.Service/Rpc/IExcelDaemonRpc.cs b/src/PptMcp.Service/Rpc/IPptDaemonRpc.cs similarity index 84% rename from src/ExcelMcp.Service/Rpc/IExcelDaemonRpc.cs rename to src/PptMcp.Service/Rpc/IPptDaemonRpc.cs index 12d00562..070e20ea 100644 --- a/src/ExcelMcp.Service/Rpc/IExcelDaemonRpc.cs +++ b/src/PptMcp.Service/Rpc/IPptDaemonRpc.cs @@ -1,13 +1,13 @@ using PolyType; using StreamJsonRpc; -namespace Sbroenne.ExcelMcp.Service.Rpc; +namespace PptMcp.Service.Rpc; /// /// Typed RPC interface for CLI↔daemon communication over named pipes. /// Replaces the hand-rolled newline-delimited JSON protocol with StreamJsonRpc (JSON-RPC 2.0). /// -/// The interface has a single method that delegates to , +/// The interface has a single method that delegates to , /// preserving the existing command routing while gaining: /// - Proper error propagation (JSON-RPC error objects instead of swallowed exceptions) /// - Standard protocol (JSON-RPC 2.0 with Content-Length framing) @@ -16,11 +16,11 @@ namespace Sbroenne.ExcelMcp.Service.Rpc; /// [JsonRpcContract] [GenerateShape(IncludeMethods = MethodShapeFlags.PublicInstance)] -public partial interface IExcelDaemonRpc +public partial interface IPptDaemonRpc { /// /// Sends a command to the daemon for execution. - /// Wraps over the pipe transport. + /// Wraps over the pipe transport. /// /// The service request with command, sessionId, and args. /// The service response indicating success/failure with optional result data. diff --git a/src/ExcelMcp.Service/ServiceClient.cs b/src/PptMcp.Service/ServiceClient.cs similarity index 93% rename from src/ExcelMcp.Service/ServiceClient.cs rename to src/PptMcp.Service/ServiceClient.cs index ae922f81..b664a214 100644 --- a/src/ExcelMcp.Service/ServiceClient.cs +++ b/src/PptMcp.Service/ServiceClient.cs @@ -1,10 +1,10 @@ -using Sbroenne.ExcelMcp.Service.Rpc; +using PptMcp.Service.Rpc; using StreamJsonRpc; -namespace Sbroenne.ExcelMcp.Service; +namespace PptMcp.Service; /// -/// Client for communicating with the ExcelMCP CLI daemon via named pipe + StreamJsonRpc. +/// Client for communicating with the PptMcp CLI daemon via named pipe + StreamJsonRpc. /// Each call creates a new pipe connection, makes one RPC call, and disconnects. /// public sealed class ServiceClient : IDisposable @@ -40,7 +40,7 @@ public async Task SendAsync(ServiceRequest request, Cancellatio await pipe.ConnectAsync((int)_connectTimeout.TotalMilliseconds, timeoutCts.Token); // Use StreamJsonRpc typed proxy for the RPC call - var proxy = JsonRpc.Attach(pipe); + var proxy = JsonRpc.Attach(pipe); try { return await proxy.ProcessCommandAsync(request); diff --git a/src/ExcelMcp.Service/ServiceProtocol.cs b/src/PptMcp.Service/ServiceProtocol.cs similarity index 90% rename from src/ExcelMcp.Service/ServiceProtocol.cs rename to src/PptMcp.Service/ServiceProtocol.cs index 9c705d3c..0b9de5c1 100644 --- a/src/ExcelMcp.Service/ServiceProtocol.cs +++ b/src/PptMcp.Service/ServiceProtocol.cs @@ -1,11 +1,11 @@ using System.Text.Json; // Re-export shared types from ComInterop for CLI internal use -using SharedProtocol = Sbroenne.ExcelMcp.ComInterop.ServiceClient.ServiceProtocol; -using SharedRequest = Sbroenne.ExcelMcp.ComInterop.ServiceClient.ServiceRequest; -using SharedResponse = Sbroenne.ExcelMcp.ComInterop.ServiceClient.ServiceResponse; +using SharedProtocol = PptMcp.ComInterop.ServiceClient.ServiceProtocol; +using SharedRequest = PptMcp.ComInterop.ServiceClient.ServiceRequest; +using SharedResponse = PptMcp.ComInterop.ServiceClient.ServiceResponse; -namespace Sbroenne.ExcelMcp.Service; +namespace PptMcp.Service; /// /// Protocol messages for CLI-to-service communication over named pipes. diff --git a/src/ExcelMcp.Service/ServiceSecurity.cs b/src/PptMcp.Service/ServiceSecurity.cs similarity index 84% rename from src/ExcelMcp.Service/ServiceSecurity.cs rename to src/PptMcp.Service/ServiceSecurity.cs index be119d92..2d8cc244 100644 --- a/src/ExcelMcp.Service/ServiceSecurity.cs +++ b/src/PptMcp.Service/ServiceSecurity.cs @@ -2,17 +2,17 @@ using System.Security.AccessControl; using System.Security.Principal; -namespace Sbroenne.ExcelMcp.Service; +namespace PptMcp.Service; /// -/// Security utilities for ExcelMCP Service named pipe communication. +/// Security utilities for PptMcp Service named pipe communication. /// Ensures per-user isolation via SID-based pipe names and ACLs. /// /// /// Pipe Name Strategy: /// -/// MCP Server: excelmcp-mcp-{SID}-{PID} (per-process isolation, each instance independent) -/// CLI daemon: excelmcp-cli-{SID} (per-user, shared across CLI invocations) +/// MCP Server: PptMcp-mcp-{SID}-{PID} (per-process isolation, each instance independent) +/// CLI daemon: PptMcp-cli-{SID} (per-user, shared across CLI invocations) /// /// Security Model: /// @@ -39,12 +39,12 @@ public static class ServiceSecurity /// /// Gets the pipe name for the MCP Server (per-process isolation). /// - public static string GetMcpPipeName() => $"excelmcp-mcp-{UserSid}-{Environment.ProcessId}"; + public static string GetMcpPipeName() => $"PptMcp-mcp-{UserSid}-{Environment.ProcessId}"; /// /// Gets the pipe name for the CLI daemon (shared across CLI invocations for the same user). /// - public static string GetCliPipeName() => $"excelmcp-cli-{UserSid}"; + public static string GetCliPipeName() => $"PptMcp-cli-{UserSid}"; /// /// Creates a secure named pipe server with ACLs restricting access to current user only. diff --git a/tests/ExcelMcp.ComInterop.Tests/Integration/Session/ExcelBatchTests.cs b/tests/ExcelMcp.ComInterop.Tests/Integration/Session/ExcelBatchTests.cs deleted file mode 100644 index 44b7abe0..00000000 --- a/tests/ExcelMcp.ComInterop.Tests/Integration/Session/ExcelBatchTests.cs +++ /dev/null @@ -1,471 +0,0 @@ -using System.Diagnostics; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; -using Xunit.Abstractions; - -namespace Sbroenne.ExcelMcp.ComInterop.Tests.Integration.Session; - -/// -/// Integration tests for ExcelBatch - verifies batch operations and COM cleanup. -/// Tests that Excel instances are reused across operations and properly cleaned up. -/// -/// LAYER RESPONSIBILITY: -/// - ✅ Test ExcelBatch.Execute() reuses Excel instance -/// - ✅ Test ExcelBatch.Dispose() COM cleanup -/// - ✅ Test ExcelBatch.Save() functionality -/// - ✅ Verify Excel.exe process termination (no leaks) -/// -/// NOTE: ExcelBatch.Dispose() handles all GC cleanup automatically. -/// Tests only need to wait for async disposal and process termination timing. -/// -/// IMPORTANT: These tests spawn and terminate Excel processes (side effects). -/// They run OnDemand only to avoid interference with normal test runs. -/// -[Trait("Category", "Integration")] -[Trait("Speed", "Slow")] -[Trait("Layer", "ComInterop")] -[Trait("Feature", "ExcelBatch")] -[Collection("Sequential")] // Disable parallelization to avoid COM interference -public class ExcelBatchTests : IAsyncLifetime -{ - private readonly ITestOutputHelper _output; - private static string? _staticTestFile; - private string? _testFileCopy; - - public ExcelBatchTests(ITestOutputHelper output) - { - _output = output; - } - - public Task InitializeAsync() - { - // Use static test file from TestFiles folder (must be pre-created) - if (_staticTestFile == null) - { - var testFolder = Path.Join(AppContext.BaseDirectory, "Integration", "Session", "TestFiles"); - _staticTestFile = Path.Join(testFolder, "batch-test-static.xlsx"); - - // Verify the static file exists - if (!File.Exists(_staticTestFile)) - { - throw new FileNotFoundException($"Static test file not found at {_staticTestFile}. " + - "Please create the batch-test-static.xlsx file in the TestFiles folder."); - } - } - - // Create a fresh copy for this test instance (in temp folder) - _testFileCopy = Path.Join(Path.GetTempPath(), $"batch-test-{Guid.NewGuid():N}.xlsx"); - File.Copy(_staticTestFile, _testFileCopy, overwrite: true); - - // Wait for any Excel processes from file creation to terminate - return Task.Delay(500); - } - - public Task DisposeAsync() - { - // Clean up this test's copy - if (_testFileCopy != null && File.Exists(_testFileCopy)) - { - File.Delete(_testFileCopy); - } - return Task.CompletedTask; - } - - private static void CleanupStaticFile() - { - if (_staticTestFile != null && File.Exists(_staticTestFile)) - { - File.Delete(_staticTestFile); - } - } - - [Fact] - public void ExecuteAsync_MultipleOperations_ReusesExcelInstance() - { - // Arrange - int operationCount = 0; - - // Act - Use batching for multiple operations - using var batch = ExcelSession.BeginBatch(_testFileCopy!); - - for (int i = 0; i < 5; i++) - { - batch.Execute((ctx, ct) => - { - operationCount++; - _output.WriteLine($"Batch operation {operationCount}"); - - // Verify we have the same context - Assert.NotNull(ctx.App); - Assert.NotNull(ctx.Book); - - return operationCount; - }); - } - - // Assert - Assert.Equal(5, operationCount); - _output.WriteLine($"✓ Completed {operationCount} batch operations"); - } - - [Fact] - public void Dispose_CleansUpComObjects_NoProcessLeak() - { - // Arrange - var startingProcesses = Process.GetProcessesByName("EXCEL"); - int startingCount = startingProcesses.Length; - - _output.WriteLine($"Excel processes before: {startingCount}"); - - // Act - var batch = ExcelSession.BeginBatch(_testFileCopy!); - - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - _ = sheet.Range["A1"].Value2; - return 0; - }); - - batch.Dispose(); - - // Wait for Excel process to fully terminate with polling - // Excel.Quit() signals shutdown but process termination is OS-controlled - // Dispose() blocks up to StaThreadJoinTimeout for COM cleanup, but process may linger briefly - var waitTimeout = TimeSpan.FromSeconds(15); // Allow reasonable time for process cleanup - var stopwatch = Stopwatch.StartNew(); - int endingCount; - do - { - Thread.Sleep(500); // Poll every 500ms - endingCount = Process.GetProcessesByName("EXCEL").Length; - _output.WriteLine($"Excel processes at {stopwatch.Elapsed.TotalSeconds:F1}s: {endingCount}"); - } - while (endingCount > startingCount && stopwatch.Elapsed < waitTimeout); - - // Assert - _output.WriteLine($"Excel processes after {stopwatch.Elapsed.TotalSeconds:F1}s: {endingCount}"); - - Assert.True(endingCount <= startingCount, - $"Excel process leak in batch! Started with {startingCount}, ended with {endingCount} after {waitTimeout.TotalSeconds}s"); - } - - [Fact] - public void Save_PersistsChanges_ToWorkbook() - { - // Arrange - string testValue = $"Test-{Guid.NewGuid():N}"; - - // Act - Write and save - using (var batch = ExcelSession.BeginBatch(_testFileCopy!)) - { - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - sheet.Range["A1"].Value2 = testValue; - return 0; - }); - - batch.Save(); - } - - // Wait for file to be released - Thread.Sleep(1000); - - // Verify - Read back the value in a new batch session - string readValue; - using (var batch = ExcelSession.BeginBatch(_testFileCopy!)) - { - readValue = batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - var value = sheet.Range["A1"].Value2; - string result = value?.ToString() ?? ""; - return result; - }); - } - - // Assert - Assert.Equal(testValue, readValue); - _output.WriteLine($"✓ Value persisted correctly: {testValue}"); - } - - [Fact] - public void WorkbookPath_ReturnsCorrectPath() - { - // Arrange & Act - using var batch = ExcelSession.BeginBatch(_testFileCopy!); - - // Assert - Assert.Equal(_testFileCopy, batch.WorkbookPath); - } - - [Fact] - public void CompleteWorkflow_CreateModifyReadSave_AllOperationsSucceed() - { - // Arrange - string sheetName = "TestData"; - string testValue1 = "Header1"; - string testValue2 = "Value1"; - string namedRangeName = "TestRange"; - - // Act - Execute complete workflow in single batch - using (var batch = ExcelSession.BeginBatch(_testFileCopy!)) - { - // Step 1: Create new worksheet - batch.Execute((ctx, ct) => - { - dynamic sheets = ctx.Book.Worksheets; - dynamic newSheet = sheets.Add(); - newSheet.Name = sheetName; - _output.WriteLine($"✓ Created worksheet: {sheetName}"); - return 0; - }); - - // Step 2: Write data to cells - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[sheetName]; - sheet.Range["A1"].Value2 = testValue1; - sheet.Range["A2"].Value2 = testValue2; - sheet.Range["B1"].Value2 = "Header2"; - sheet.Range["B2"].Formula = "=LEN(A2)"; - _output.WriteLine($"✓ Wrote data to cells A1, A2, B1, B2"); - return 0; - }); - - // Step 3: Create named range - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[sheetName]; - ctx.Book.Names.Add(namedRangeName, $"={sheetName}!$A$1:$B$2"); - _output.WriteLine($"✓ Created named range: {namedRangeName}"); - return 0; - }); - - // Step 4: Read data back to verify - var readData = batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[sheetName]; - string a1 = sheet.Range["A1"].Value2?.ToString() ?? ""; - string a2 = sheet.Range["A2"].Value2?.ToString() ?? ""; - string b1 = sheet.Range["B1"].Value2?.ToString() ?? ""; - double b2 = Convert.ToDouble(sheet.Range["B2"].Value2); // Formula result - _output.WriteLine($"✓ Read back: A1={a1}, A2={a2}, B1={b1}, B2={b2}"); - return (a1, a2, b1, b2); - }); - - // Verify intermediate state - Assert.Equal(testValue1, readData.a1); - Assert.Equal(testValue2, readData.a2); - Assert.Equal("Header2", readData.b1); - Assert.Equal(6.0, Convert.ToDouble(readData.b2)); // LEN("Value1") = 6 - - // Step 5: Modify existing data - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[sheetName]; - sheet.Range["A2"].Value2 = "Modified"; - _output.WriteLine("✓ Modified A2 cell"); - return 0; - }); - - // Step 6: Save all changes - batch.Save(); - _output.WriteLine("✓ Saved workbook"); - } - - // Wait for file to be released - Thread.Sleep(1000); - - // Verify - Open in new batch and check all changes persisted - using (var batch = ExcelSession.BeginBatch(_testFileCopy!)) - { - var verifyData = batch.Execute((ctx, ct) => - { - // Check worksheet exists - bool sheetExists = false; - dynamic sheets = ctx.Book.Worksheets; - for (int i = 1; i <= sheets.Count; i++) - { - dynamic sheet = sheets[i]; - if (sheet.Name == sheetName) - { - sheetExists = true; - break; - } - } - - // Read cell values - dynamic dataSheet = ctx.Book.Worksheets[sheetName]; - string a1 = dataSheet.Range["A1"].Value2?.ToString() ?? ""; - string a2 = dataSheet.Range["A2"].Value2?.ToString() ?? ""; - double b2 = Convert.ToDouble(dataSheet.Range["B2"].Value2); - - // Check named range exists - bool namedRangeExists = false; - dynamic names = ctx.Book.Names; - for (int i = 1; i <= names.Count; i++) - { - dynamic name = names[i]; - if (name.Name == namedRangeName) - { - namedRangeExists = true; - break; - } - } - - return (sheetExists, a1, a2, b2, namedRangeExists); - }); - - // Assert - All changes persisted - Assert.True(verifyData.sheetExists, "Worksheet should exist after save"); - Assert.Equal(testValue1, verifyData.a1); - Assert.Equal("Modified", verifyData.a2); - Assert.Equal(8.0, verifyData.b2); // LEN("Modified") = 8 - Assert.True(verifyData.namedRangeExists, "Named range should exist after save"); - _output.WriteLine("✓ All workflow changes persisted correctly"); - } - } - - [Fact] - public async Task ParallelBatches_TwoConcurrentBatches_NoExcelProcessLeak() - { - // Arrange - const int batchCount = 2; - var testFileCopies = new List(); - - // Create fresh copies for parallel test - for (int i = 0; i < batchCount; i++) - { - string copy = Path.Join(Path.GetTempPath(), $"batch-test-parallel-{i}-{Guid.NewGuid():N}.xlsx"); - File.Copy(_staticTestFile!, copy, overwrite: true); - testFileCopies.Add(copy); - } - - var startingProcesses = Process.GetProcessesByName("EXCEL"); - int startingCount = startingProcesses.Length; - _output.WriteLine($"Excel processes before parallel batches: {startingCount}"); - - try - { - // Act - Run 2 batches in parallel - var tasks = testFileCopies.Select((testFile, index) => - { - return Task.Run(() => - { - using var batch = ExcelSession.BeginBatch(testFile); - - // Perform multiple operations per batch - for (int op = 0; op < 3; op++) - { - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - sheet.Range[$"A{op + 1}"].Value2 = $"Batch{index}-Op{op}"; - return 0; - }); - } - - _output.WriteLine($"✓ Batch {index} completed"); - - return index; - }); - }).ToArray(); - - // Wait for all batches to complete - var results = await Task.WhenAll(tasks); - - Assert.Equal(batchCount, results.Length); - _output.WriteLine($"✓ All {batchCount} parallel batches completed"); - - // Wait for Excel processes to terminate - await Task.Delay(5000); - - // Assert - No process leak - var endingProcesses = Process.GetProcessesByName("EXCEL"); - int endingCount = endingProcesses.Length; - _output.WriteLine($"Excel processes after parallel batches: {endingCount}"); - - Assert.True(endingCount <= startingCount + 2, // Allow some tolerance for cleanup timing - $"Excel process leak in parallel batches! Started with {startingCount}, ended with {endingCount}"); - } - finally - { - // Cleanup parallel test files - foreach (var testFile in testFileCopies.Where(File.Exists)) - { -#pragma warning disable CA1031 // Intentional: best-effort test cleanup - try { File.Delete(testFile); } catch (Exception) { /* Best effort cleanup */ } -#pragma warning restore CA1031 - } - } - } - - [Fact] - [Trait("Category", "Integration")] - [Trait("Feature", "FileLocking")] - public void Constructor_FileLockedByAnotherProcess_ThrowsInvalidOperationException() - { - // Arrange - Create a separate test file for locking test - var lockedTestFile = Path.Join(Path.GetTempPath(), $"batch-test-locked-{Guid.NewGuid():N}.xlsx"); - File.Copy(_staticTestFile!, lockedTestFile, overwrite: true); - - try - { - // Lock the file by opening with exclusive access (simulating Excel or another process) - using var fileLock = new FileStream( - lockedTestFile, - FileMode.Open, - FileAccess.ReadWrite, - FileShare.None); - - // Act & Assert - Attempting to create ExcelBatch should fail immediately - var ex = Assert.Throws(() => - { - var batch = ExcelSession.BeginBatch(lockedTestFile); - batch.Dispose(); - }); - - // Verify error message is clear and actionable - Assert.Contains("already open", ex.Message, StringComparison.OrdinalIgnoreCase); - Assert.Contains("close the file", ex.Message, StringComparison.OrdinalIgnoreCase); - Assert.Contains("exclusive access", ex.Message, StringComparison.OrdinalIgnoreCase); - - _output.WriteLine($"✓ File locking detected successfully"); - _output.WriteLine($"Error message: {ex.Message}"); - } - finally - { - // Cleanup - if (File.Exists(lockedTestFile)) - { -#pragma warning disable CA1031 // Intentional: best-effort test cleanup - try { File.Delete(lockedTestFile); } catch (Exception) { /* Best effort - file may be locked */ } -#pragma warning restore CA1031 - } - } - } - - // Note: Testing file-already-open scenario is complex because: - // 1. Excel's behavior when opening an already-open file can vary (hang, prompt, or succeed) - // 2. The error detection code in ExcelBatch.cs catches COM Error 0x800A03EC - // 3. This test would require simulating Excel having the file open externally - // - // The error handling code is verified through: - // - Manual testing: Open file in Excel UI, then try automation - // - Real-world usage: Users will encounter this if they forget to close files - // - Code review: Error message is clear and actionable - // - // UPDATE: We now have a test (Constructor_FileLockedByAnotherProcess_ThrowsInvalidOperationException) - // that verifies the OS-level file locking check without requiring Excel to be running. - // - // Keeping this comment as documentation that the scenario is handled in production code. -} - - - - - - - diff --git a/tests/ExcelMcp.ComInterop.Tests/Integration/Session/TestFiles/batch-test-static.xlsx b/tests/ExcelMcp.ComInterop.Tests/Integration/Session/TestFiles/batch-test-static.xlsx deleted file mode 100644 index d671dad0..00000000 Binary files a/tests/ExcelMcp.ComInterop.Tests/Integration/Session/TestFiles/batch-test-static.xlsx and /dev/null differ diff --git a/tests/ExcelMcp.ComInterop.Tests/Unit/ExcelContextTests.cs b/tests/ExcelMcp.ComInterop.Tests/Unit/ExcelContextTests.cs deleted file mode 100644 index 0e4c0819..00000000 --- a/tests/ExcelMcp.ComInterop.Tests/Unit/ExcelContextTests.cs +++ /dev/null @@ -1,108 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.ComInterop.Tests.Unit; - -/// -/// Unit tests for ExcelContext - validates constructor and property behavior. -/// This class is a simple data holder, so tests focus on path validation and immutability. -/// Note: Excel.Application and Excel.Workbook COM objects cannot be mocked in unit tests, -/// so these tests use null! for those parameters and verify only what is testable. -/// -[Trait("Category", "Unit")] -[Trait("Speed", "Fast")] -[Trait("Layer", "ComInterop")] -public class ExcelContextTests -{ - [Fact] - public void Constructor_WithValidArguments_SetsWorkbookPathCorrectly() - { - // Arrange - string workbookPath = @"C:\test\workbook.xlsx"; - - // Act & Assert - Constructor throws ArgumentNullException for null COM objects, - // which is expected behavior. WorkbookPath validation is tested separately. - var ex = Assert.Throws(() => - new ExcelContext(workbookPath, null!, null!)); - - // When null is passed, the constructor throws on the first null param (excel) - Assert.NotNull(ex); - } - - [Fact] - public void Constructor_WithNullWorkbookPath_ThrowsArgumentNullException() - { - // Arrange - string? workbookPath = null; - - // Act & Assert - var ex = Assert.Throws(() => - new ExcelContext(workbookPath!, null!, null!)); - - Assert.Equal("workbookPath", ex.ParamName); - } - - [Fact] - public void Constructor_WithNullExcel_ThrowsArgumentNullException() - { - // Arrange - string workbookPath = @"C:\test\workbook.xlsx"; - - // Act & Assert - var ex = Assert.Throws(() => - new ExcelContext(workbookPath, null!, null!)); - - Assert.Equal("excel", ex.ParamName); - } - - [Fact] - public void Constructor_WithNullWorkbookPath_ThrowsBeforeNullExcel() - { - // Arrange - string? workbookPath = null; - - // Act & Assert - WorkbookPath is validated first - var ex = Assert.Throws(() => - new ExcelContext(workbookPath!, null!, null!)); - - Assert.Equal("workbookPath", ex.ParamName); - } - - [Fact] - public void Constructor_WorkbookPathValidation_RejectsNull() - { - // Arrange & Act & Assert - var ex = Assert.Throws(() => - new ExcelContext(null!, null!, null!)); - - Assert.Equal("workbookPath", ex.ParamName); - } - - [Theory] - [InlineData(@"C:\test\workbook.xlsx")] - [InlineData(@"\\server\share\workbook.xlsm")] - [InlineData(@"D:\Documents\My Workbook.xlsx")] - [InlineData(@"workbook.xlsx")] // Relative path - public void Constructor_WithNullExcelAnyPath_ThrowsArgumentNullException(string workbookPath) - { - // Act & Assert - Path is validated, then excel COM object is validated - var ex = Assert.Throws(() => - new ExcelContext(workbookPath, null!, null!)); - - // excel is the first COM parameter validated after workbookPath - Assert.Equal("excel", ex.ParamName); - } - - [Fact] - public void Constructor_NullWorkbookPath_ThrowsWithCorrectParamName() - { - // Arrange - Simulates null path being passed - Assert.Throws(() => - new ExcelContext(null!, null!, null!)); - } -} - - - - - diff --git a/tests/ExcelMcp.Core.Tests/Commands/Calculation/CalculationModeCommandsTests.cs b/tests/ExcelMcp.Core.Tests/Commands/Calculation/CalculationModeCommandsTests.cs deleted file mode 100644 index d21507b4..00000000 --- a/tests/ExcelMcp.Core.Tests/Commands/Calculation/CalculationModeCommandsTests.cs +++ /dev/null @@ -1,196 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands.Calculation; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Calculation; - -/// -/// Integration tests for calculation mode control (get-mode, set-mode, calculate actions). -/// Tests validate explicit control over Excel's automatic/manual calculation mode. -/// -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("Feature", "CalculationMode")] -[Trait("RequiresExcel", "true")] -[Trait("Speed", "Medium")] -public class CalculationModeCommandsTests : IClassFixture -{ - private readonly CalculationModeCommands _commands; - private readonly TempDirectoryFixture _fixture; - - public CalculationModeCommandsTests(TempDirectoryFixture fixture) - { - _commands = new CalculationModeCommands(); - _fixture = fixture; - } - - /// - /// Verify get-mode returns current calculation state as automatic (default). - /// - [Fact] - public void GetMode_ReturnsAutomaticByDefault() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - // Act - using var batch = ExcelSession.BeginBatch(testFile); - var result = _commands.GetMode(batch); - - // Assert - Assert.True(result.Success); - Assert.Equal("automatic", result.Mode); - Assert.Equal(-4105, result.ModeValue); // xlCalculationAutomatic - } - - /// - /// Verify set-mode can switch to manual. - /// - [Fact] - public void SetMode_ToManual_Succeeds() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - // Act - using var batch = ExcelSession.BeginBatch(testFile); - var setResult = _commands.SetMode(batch, CalculationMode.Manual); - - // Assert - Assert.True(setResult.Success); - - // Verify it's actually manual - var getResult = _commands.GetMode(batch); - Assert.Equal("manual", getResult.Mode); - Assert.Equal(-4135, getResult.ModeValue); // xlCalculationManual - } - - /// - /// Verify set-mode can switch to semi-automatic. - /// - [Fact] - public void SetMode_ToSemiAutomatic_Succeeds() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - // Act - using var batch = ExcelSession.BeginBatch(testFile); - var setResult = _commands.SetMode(batch, CalculationMode.SemiAutomatic); - - // Assert - Assert.True(setResult.Success); - - // Verify it's actually semi-automatic - var getResult = _commands.GetMode(batch); - Assert.Equal("semi-automatic", getResult.Mode); - Assert.Equal(2, getResult.ModeValue); // xlCalculationSemiautomatic - } - - /// - /// Verify calculate-workbook scope succeeds. - /// - [Fact] - public void Calculate_WorkbookScope_Succeeds() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - // Act - using var batch = ExcelSession.BeginBatch(testFile); - - // Switch to manual mode first - _commands.SetMode(batch, CalculationMode.Manual); - - // Calculate workbook - var result = _commands.Calculate(batch, CalculationScope.Workbook); - - // Assert - Assert.True(result.Success); - } - - /// - /// Verify calculate-sheet scope requires sheet name. - /// - [Fact] - public void Calculate_SheetScope_RequiresSheetName() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - // Act - using var batch = ExcelSession.BeginBatch(testFile); - var result = _commands.Calculate(batch, CalculationScope.Sheet, null); - - // Assert - Assert.False(result.Success); - Assert.Contains("sheetName is required", result.ErrorMessage ?? ""); - } - - /// - /// Verify calculate-sheet scope succeeds with sheet name. - /// - [Fact] - public void Calculate_SheetScope_WithValidSheetName_Succeeds() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - // Act - using var batch = ExcelSession.BeginBatch(testFile); - - // Switch to manual mode - _commands.SetMode(batch, CalculationMode.Manual); - - // Calculate specific sheet - var result = _commands.Calculate(batch, CalculationScope.Sheet, "Sheet1"); - - // Assert - Assert.True(result.Success); - } - - /// - /// Verify calculate-range scope requires both sheet and range. - /// - [Fact] - public void Calculate_RangeScope_RequiresBothSheetAndRange() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - // Act - using var batch = ExcelSession.BeginBatch(testFile); - var result = _commands.Calculate(batch, CalculationScope.Range, "Sheet1", null); - - // Assert - Assert.False(result.Success); - Assert.Contains("rangeAddress are required", result.ErrorMessage ?? ""); - } - - /// - /// Verify calculate-range scope succeeds with both parameters. - /// - [Fact] - public void Calculate_RangeScope_WithValidParameters_Succeeds() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - // Act - using var batch = ExcelSession.BeginBatch(testFile); - - // Switch to manual mode - _commands.SetMode(batch, CalculationMode.Manual); - - // Calculate specific range - var result = _commands.Calculate(batch, CalculationScope.Range, "Sheet1", "A1:C10"); - - // Assert - Assert.True(result.Success); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Commands/ChartCommandsTests.Appearance.cs b/tests/ExcelMcp.Core.Tests/Commands/ChartCommandsTests.Appearance.cs deleted file mode 100644 index 814dc401..00000000 --- a/tests/ExcelMcp.Core.Tests/Commands/ChartCommandsTests.Appearance.cs +++ /dev/null @@ -1,546 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands.Chart; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands; - -/// -/// Integration tests for Chart appearance operations (SetChartType, SetTitle, SetAxisTitle, ShowLegend, SetStyle, Get/SetAxisNumberFormat). -/// -public partial class ChartCommandsTests -{ - [Fact] - public void SetChartType_ExistingChart_ChangesType() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.ColumnClustered, 50, 50); - Assert.Equal(ChartType.ColumnClustered, createResult.ChartType); - - // Act - Change to Line chart - _commands.SetChartType(batch, createResult.ChartName, ChartType.Line); - - // Assert - Verify type changed - var readResult = _commands.Read(batch, createResult.ChartName); - Assert.Equal(ChartType.Line, readResult.ChartType); - } - - [Fact] - public void SetTitle_ValidTitle_SetsChartTitle() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B3", ChartType.Pie, 50, 50); - - // Act - _commands.SetTitle(batch, createResult.ChartName, "Sales by Quarter"); - - // Assert - Verify title set - var readResult = _commands.Read(batch, createResult.ChartName); - Assert.Equal("Sales by Quarter", readResult.Title); - } - - [Fact] - public void SetTitle_EmptyString_HidesTitle() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B3", ChartType.BarClustered, 50, 50); - _commands.SetTitle(batch, createResult.ChartName, "Initial Title"); - - // Act - Hide title with empty string - _commands.SetTitle(batch, createResult.ChartName, ""); - - // Assert - Verify title hidden - var readResult = _commands.Read(batch, createResult.ChartName); - Assert.Null(readResult.Title); - } - - [Fact] - public void SetAxisTitle_CategoryAxis_SetsTitleSuccessfully() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.ColumnClustered, 50, 50); - - // Act & Assert - void operation, no exception means success - _commands.SetAxisTitle(batch, createResult.ChartName, ChartAxisType.Category, "Months"); - } - - [Fact] - public void SetAxisTitle_ValueAxis_SetsTitleSuccessfully() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.BarClustered, 50, 50); - - // Act & Assert - void operation, no exception means success - _commands.SetAxisTitle(batch, createResult.ChartName, ChartAxisType.Value, "Revenue ($)"); - } - - [Fact] - public void ShowLegend_WithPosition_DisplaysLegendAtPosition() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create test data and chart - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - sheet.Range["A1:C4"].Value2 = new object[,] { - { "X", "Series1", "Series2" }, - { "A", 10, 20 }, - { "B", 15, 25 }, - { "C", 20, 30 } - }; - return 0; - }); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:C4", ChartType.Line, 50, 50); - - // Act - Show legend at bottom - _commands.ShowLegend(batch, createResult.ChartName, true, LegendPosition.Bottom); - - // Assert - Verify legend visible - var readResult = _commands.Read(batch, createResult.ChartName); - Assert.True(readResult.HasLegend); - } - - [Fact] - public void ShowLegend_HideLegend_RemovesLegend() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B3", ChartType.Area, 50, 50); - _commands.ShowLegend(batch, createResult.ChartName, true, LegendPosition.Right); // Show first - - // Act - Hide legend - _commands.ShowLegend(batch, createResult.ChartName, false); - - // Assert - Verify legend hidden - var readResult = _commands.Read(batch, createResult.ChartName); - Assert.False(readResult.HasLegend); - } - - [Fact] - public void SetStyle_ValidStyleId_AppliesStyle() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.ColumnClustered, 50, 50); - - // Act & Assert - void operation, no exception means success - _commands.SetStyle(batch, createResult.ChartName, 10); - } - - [Fact] - public void SetStyle_InvalidStyleId_ReturnsError() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B3", ChartType.Pie, 50, 50); - - // Act & Assert - Invalid style ID should throw exception - var exception = Assert.Throws(() => - _commands.SetStyle(batch, createResult.ChartName, 999)); - Assert.Contains("between 1 and 48", exception.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void SetChartType_MultipleTypes_AllWorkCorrectly() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B5", ChartType.ColumnClustered, 50, 50); - - // Act & Assert - Test multiple chart type changes - var chartTypes = new[] { ChartType.Line, ChartType.Area, ChartType.BarClustered, ChartType.XYScatter, ChartType.Pie }; - - foreach (var chartType in chartTypes) - { - _commands.SetChartType(batch, createResult.ChartName, chartType); - var readResult = _commands.Read(batch, createResult.ChartName); - Assert.Equal(chartType, readResult.ChartType); - } - } - - [Fact] - public void ShowLegend_DifferentPositions_AllWorkCorrectly() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:C4", ChartType.ColumnClustered, 50, 50); - - // Act & Assert - Test all legend positions - var positions = new[] { - LegendPosition.Bottom, - LegendPosition.Top, - LegendPosition.Left, - LegendPosition.Right, - LegendPosition.Corner - }; - - foreach (var position in positions) - _commands.ShowLegend(batch, createResult.ChartName, true, position); - } - - [Fact] - public void GetAxisNumberFormat_ValueAxis_ReturnsCurrentFormat() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.ColumnClustered, 50, 50); - - // Act - var format = _commands.GetAxisNumberFormat(batch, createResult.ChartName, ChartAxisType.Value); - - // Assert - Default format is typically "General" - Assert.NotNull(format); - } - - [Fact] - public void SetAxisNumberFormat_ValueAxis_SetsFormatSuccessfully() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.ColumnClustered, 50, 50); - - // Act - Set millions format - _commands.SetAxisNumberFormat(batch, createResult.ChartName, ChartAxisType.Value, "$#,##0,,\"M\""); - - // Assert - Verify format was set - var format = _commands.GetAxisNumberFormat(batch, createResult.ChartName, ChartAxisType.Value); - Assert.Equal("$#,##0,,\"M\"", format); - } - - [Fact] - public void SetAxisNumberFormat_CategoryAxis_SetsFormatSuccessfully() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - // Create test data with dates - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - sheet.Range["A1:B4"].Value2 = new object[,] { - { "Date", "Sales" }, - { 45658, 100 }, // Jan 1, 2025 - { 45689, 150 }, // Feb 1, 2025 - { 45717, 200 } // Mar 1, 2025 - }; - return 0; - }); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.Line, 50, 50); - - // Act - Set date format on category axis - _commands.SetAxisNumberFormat(batch, createResult.ChartName, ChartAxisType.Category, "mmm-yy"); - - // Assert - Verify format was set - var format = _commands.GetAxisNumberFormat(batch, createResult.ChartName, ChartAxisType.Category); - Assert.Equal("mmm-yy", format); - } - - [Fact] - public void SetAxisNumberFormat_PercentageFormat_SetsFormatSuccessfully() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - // Create test data - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - sheet.Range["A1:B4"].Value2 = new object[,] { - { "Item", "Rate" }, - { "A", 0.25 }, - { "B", 0.50 }, - { "C", 0.75 } - }; - return 0; - }); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.BarClustered, 50, 50); - - // Act - Set percentage format - _commands.SetAxisNumberFormat(batch, createResult.ChartName, ChartAxisType.Value, "0%"); - - // Assert - Verify format was set - var format = _commands.GetAxisNumberFormat(batch, createResult.ChartName, ChartAxisType.Value); - Assert.Equal("0%", format); - } - - [Fact] - public void SetAxisNumberFormat_NonExistentChart_ThrowsException() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - // Act & Assert - Non-existent chart should throw - var exception = Assert.Throws(() => - _commands.SetAxisNumberFormat(batch, "NonExistentChart", ChartAxisType.Value, "#,##0")); - Assert.Contains("not found", exception.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void GetAxisNumberFormat_NonExistentChart_ThrowsException() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - // Act & Assert - Non-existent chart should throw - var exception = Assert.Throws(() => - _commands.GetAxisNumberFormat(batch, "NonExistentChart", ChartAxisType.Value)); - Assert.Contains("not found", exception.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void SetAxisNumberFormat_MultipleFormats_AllWorkCorrectly() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.ColumnClustered, 50, 50); - - // Act & Assert - Test multiple format changes - var formats = new[] { "#,##0", "$#,##0", "#,##0.00", "$#,##0,,\"M\"", "0.0E+0" }; - - foreach (var fmt in formats) - { - _commands.SetAxisNumberFormat(batch, createResult.ChartName, ChartAxisType.Value, fmt); - var result = _commands.GetAxisNumberFormat(batch, createResult.ChartName, ChartAxisType.Value); - Assert.Equal(fmt, result); - } - } - - // === PLACEMENT TESTS === - - [Fact] - public void SetPlacement_MoveAndSize_SetsPlacementMode() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.ColumnClustered, 50, 50); - - // Act - Set placement to MoveAndSize (1 = xlMoveAndSizeWithCells) - _commands.SetPlacement(batch, createResult.ChartName, 1); - - // Assert - Verify placement changed - var readResult = _commands.Read(batch, createResult.ChartName); - Assert.Equal(1, readResult.Placement); - } - - [Fact] - public void SetPlacement_MoveOnly_SetsPlacementMode() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.Line, 50, 50); - - // Act - Set placement to Move (2 = xlMove) - _commands.SetPlacement(batch, createResult.ChartName, 2); - - // Assert - Verify placement changed - var readResult = _commands.Read(batch, createResult.ChartName); - Assert.Equal(2, readResult.Placement); - } - - [Fact] - public void SetPlacement_FreeFloating_SetsPlacementMode() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.Pie, 50, 50); - - // Act - Set placement to FreeFloating (3 = xlFreeFloating) - _commands.SetPlacement(batch, createResult.ChartName, 3); - - // Assert - Verify placement changed - var readResult = _commands.Read(batch, createResult.ChartName); - Assert.Equal(3, readResult.Placement); - } - - [Fact] - public void SetPlacement_InvalidValue_ThrowsException() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.ColumnClustered, 50, 50); - - // Act & Assert - Invalid placement value should throw - var exception = Assert.Throws(() => - _commands.SetPlacement(batch, createResult.ChartName, 5)); - Assert.Contains("placement", exception.ParamName, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void SetPlacement_NonExistentChart_ThrowsException() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - // Act & Assert - Non-existent chart should throw - var exception = Assert.Throws(() => - _commands.SetPlacement(batch, "NonExistentChart", 1)); - Assert.Contains("not found", exception.Message, StringComparison.OrdinalIgnoreCase); - } - - // === FIT TO RANGE TESTS === - - [Fact] - public void FitToRange_ValidRange_ResizesChartToMatchRange() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.ColumnClustered, 50, 50, 400, 300); - - // Act - Fit chart to a specific range - _commands.FitToRange(batch, createResult.ChartName, "Sheet1", "E5:J15"); - - // Assert - Verify chart position/size changed (can verify via Read that chart still exists) - var readResult = _commands.Read(batch, createResult.ChartName); - Assert.NotNull(readResult); - - // TopLeftCell should now be E5 (or close to it, depending on exact positioning) - Assert.NotNull(readResult.TopLeftCell); - } - - [Fact] - public void FitToRange_DifferentSheet_ThrowsOrMovesChart() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - // Create test data, chart, and a second sheet - batch.Execute((ctx, ct) => - { - // Create second sheet - dynamic newSheet = ctx.Book.Worksheets.Add(); - newSheet.Name = "Sheet2"; - return 0; - }); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.ColumnClustered, 50, 50); - - // Act - Try to fit chart to range on same sheet - _commands.FitToRange(batch, createResult.ChartName, "Sheet1", "E5:H10"); - - // Assert - Verify chart moved - var readResult = _commands.Read(batch, createResult.ChartName); - Assert.NotNull(readResult); - } - - [Fact] - public void FitToRange_NonExistentChart_ThrowsException() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - // Act & Assert - Non-existent chart should throw - var exception = Assert.Throws(() => - _commands.FitToRange(batch, "NonExistentChart", "Sheet1", "A1:D10")); - Assert.Contains("not found", exception.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void FitToRange_InvalidRangeAddress_ThrowsException() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.ColumnClustered, 50, 50); - - // Act & Assert - Invalid range should throw - Assert.ThrowsAny(() => - _commands.FitToRange(batch, createResult.ChartName, "Sheet1", "InvalidRange!!!")); - } - - // === ANCHOR CELLS TESTS === - - [Fact] - public void Read_ChartCreatedAtPosition_ReturnsAnchorCells() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - // Create chart at position left=50, top=50 - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.ColumnClustered, 50, 50, 400, 300); - - // Act - var readResult = _commands.Read(batch, createResult.ChartName); - - // Assert - Anchor cells should be populated - Assert.NotNull(readResult.TopLeftCell); - Assert.NotNull(readResult.BottomRightCell); - - // TopLeftCell should be a valid cell address (e.g., "$A$1", "$B$2", etc.) - Assert.Matches(@"\$[A-Z]+\$\d+", readResult.TopLeftCell); - Assert.Matches(@"\$[A-Z]+\$\d+", readResult.BottomRightCell); - } - - [Fact] - public void Read_ChartAfterFitToRange_ReturnsUpdatedAnchorCells() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.ColumnClustered, 50, 50); - - // Get initial anchor cells - var initialRead = _commands.Read(batch, createResult.ChartName); - var initialTopLeft = initialRead.TopLeftCell; - - // Act - Fit chart to a different range - _commands.FitToRange(batch, createResult.ChartName, "Sheet1", "F10:K20"); - - // Assert - Anchor cells should have changed - var afterRead = _commands.Read(batch, createResult.ChartName); - Assert.NotEqual(initialTopLeft, afterRead.TopLeftCell); - - // The new TopLeftCell should reflect the new position (around F10) - Assert.NotNull(afterRead.TopLeftCell); - Assert.NotNull(afterRead.BottomRightCell); - } - - [Fact] - public void Read_ChartWithPlacement_ReturnsPlacementValue() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.ColumnClustered, 50, 50); - - // Act - var readResult = _commands.Read(batch, createResult.ChartName); - - // Assert - Placement should be populated with a valid value (1, 2, or 3) - Assert.NotNull(readResult.Placement); - Assert.InRange(readResult.Placement.Value, 1, 3); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Commands/ChartCommandsTests.BugRegression.cs b/tests/ExcelMcp.Core.Tests/Commands/ChartCommandsTests.BugRegression.cs deleted file mode 100644 index fc80080d..00000000 --- a/tests/ExcelMcp.Core.Tests/Commands/ChartCommandsTests.BugRegression.cs +++ /dev/null @@ -1,237 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands.Chart; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands; - -/// -/// Regression tests for Bug Report 2026-02-23. -/// Bug 1: chart(action: 'create-from-range') fails with COM error 0x800A03EC -/// when data is not at row 1 or sheet name contains spaces. -/// -public partial class ChartCommandsTests -{ - /// - /// Regression: create-from-range fails when data starts at a non-first row (e.g., A9:D14). - /// The user reported COM error 0x800A03EC when creating a Line chart from A9:D14. - /// This reproduces the exact scenario from the bug report. - /// - [Fact] - public void CreateFromRange_DataAtNonFirstRow_CreatesChart() - { - // Arrange — isolated file with data only at A9:D14 (rows 1-8 empty) - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - // Write data at A9:D14 (6 rows: 1 header + 5 data) - batch.Execute((ctx, ct) => - { - dynamic? sheet = null; - try - { - sheet = ctx.Book.Worksheets[1]; - sheet.Range["A9:D14"].Value2 = new object[,] - { - { "Quarter", "Revenue", "Cost", "Profit" }, - { "Q1", 1200, 800, 400 }, - { "Q2", 1500, 900, 600 }, - { "Q3", 1800, 1000, 800 }, - { "Q4", 2100, 1100, 1000 }, - { "Q5", 2400, 1200, 1200 } - }; - return 0; - } - finally - { - ComUtilities.Release(ref sheet); - } - }); - - // Act — create Line chart from A9:D14 (exactly as reported) - var result = _commands.CreateFromRange( - batch, - "Sheet1", - "A9:D14", - ChartType.Line, - 50, 50, 400, 300, - "BugRegression_NonFirstRow"); - - // Assert - Assert.True(result.Success, $"CreateFromRange failed: chart was not created"); - Assert.Equal("BugRegression_NonFirstRow", result.ChartName); - Assert.Equal(ChartType.Line, result.ChartType); - - // Verify chart actually exists in workbook - var charts = _commands.List(batch); - Assert.Contains(charts, c => c.Name == "BugRegression_NonFirstRow"); - } - - /// - /// Regression: create-from-range fails when sheet name contains spaces. - /// The range address is constructed as "{sheetName}!{rangeAddress}" but Excel COM - /// requires single quotes around sheet names with spaces: "'Sheet Name'!A1:D6". - /// Without quoting, Application.Range["Deal Summary!A9:D14"] throws 0x800A03EC. - /// - [Fact] - public void CreateFromRange_SheetNameWithSpaces_CreatesChart() - { - // Arrange — create a sheet with spaces in name - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - batch.Execute((ctx, ct) => - { - dynamic? sheets = null; - dynamic? newSheet = null; - try - { - sheets = ctx.Book.Worksheets; - newSheet = sheets.Add(); - newSheet.Name = "Deal Summary"; - newSheet.Range["A1:D6"].Value2 = new object[,] - { - { "Product", "Q1", "Q2", "Q3" }, - { "Widget A", 100, 150, 200 }, - { "Widget B", 200, 250, 300 }, - { "Widget C", 300, 350, 400 }, - { "Widget D", 400, 450, 500 }, - { "Widget E", 500, 550, 600 } - }; - return 0; - } - finally - { - ComUtilities.Release(ref newSheet); - ComUtilities.Release(ref sheets); - } - }); - - // Act — create chart on sheet with spaces in name - var result = _commands.CreateFromRange( - batch, - "Deal Summary", - "A1:D6", - ChartType.Line, - 50, 50, 400, 300, - "BugRegression_SpacesInName"); - - // Assert - Assert.True(result.Success, $"CreateFromRange failed for sheet with spaces in name"); - Assert.Equal("BugRegression_SpacesInName", result.ChartName); - Assert.Equal("Deal Summary", result.SheetName); - } - - /// - /// Regression: Combined scenario — sheet name with spaces AND data at non-first row. - /// This is the exact scenario from the Bayer AG deal sizing bug report. - /// - [Fact] - public void CreateFromRange_SheetWithSpacesAndDataAtNonFirstRow_CreatesChart() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - batch.Execute((ctx, ct) => - { - dynamic? sheets = null; - dynamic? newSheet = null; - try - { - sheets = ctx.Book.Worksheets; - newSheet = sheets.Add(); - newSheet.Name = "Export Data"; - // Data starts at row 9, like the bug report - newSheet.Range["A9:D14"].Value2 = new object[,] - { - { "Service", "Current", "Proposed", "Delta" }, - { "Compute", 50000, 45000, -5000 }, - { "Storage", 20000, 18000, -2000 }, - { "Network", 15000, 14000, -1000 }, - { "Database", 30000, 25000, -5000 }, - { "AI/ML", 10000, 12000, 2000 } - }; - return 0; - } - finally - { - ComUtilities.Release(ref newSheet); - ComUtilities.Release(ref sheets); - } - }); - - // Act - var result = _commands.CreateFromRange( - batch, - "Export Data", - "A9:D14", - ChartType.Line, - 50, 50, 400, 300, - "BugRegression_Combined"); - - // Assert - Assert.True(result.Success, $"CreateFromRange failed for combined scenario"); - Assert.Equal("BugRegression_Combined", result.ChartName); - Assert.Equal("Export Data", result.SheetName); - Assert.Equal(ChartType.Line, result.ChartType); - } - - /// - /// Regression: Verify that create-from-table works as workaround for the same data layout. - /// The bug report confirms create-from-table succeeds where create-from-range fails. - /// This test validates the workaround and serves as a comparison baseline. - /// - [Fact] - public void CreateFromTable_DataAtNonFirstRow_SucceedsAsWorkaround() - { - // Arrange — same data layout as the failing create-from-range scenario - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - batch.Execute((ctx, ct) => - { - dynamic? sheet = null; - dynamic? listObjects = null; - dynamic? table = null; - try - { - sheet = ctx.Book.Worksheets[1]; - sheet.Range["A9:D14"].Value2 = new object[,] - { - { "Quarter", "Revenue", "Cost", "Profit" }, - { "Q1", 1200, 800, 400 }, - { "Q2", 1500, 900, 600 }, - { "Q3", 1800, 1000, 800 }, - { "Q4", 2100, 1100, 1000 }, - { "Q5", 2400, 1200, 1200 } - }; - listObjects = sheet.ListObjects; - table = listObjects.Add(1, sheet.Range["A9:D14"], null, 1); // xlYes = 1 - table.Name = "BugWorkaroundTable"; - return 0; - } - finally - { - ComUtilities.Release(ref table); - ComUtilities.Release(ref listObjects); - ComUtilities.Release(ref sheet); - } - }); - - // Act — create chart from table (workaround from bug report) - var result = _commands.CreateFromTable( - batch, - "BugWorkaroundTable", - "Sheet1", - ChartType.Line, - 50, 50, 400, 300, - "BugWorkaround_Table"); - - // Assert — this should always succeed - Assert.True(result.Success, $"CreateFromTable (workaround) failed unexpectedly"); - Assert.Equal("BugWorkaround_Table", result.ChartName); - Assert.Equal(ChartType.Line, result.ChartType); - } -} diff --git a/tests/ExcelMcp.Core.Tests/Commands/ChartCommandsTests.DataSource.cs b/tests/ExcelMcp.Core.Tests/Commands/ChartCommandsTests.DataSource.cs deleted file mode 100644 index 020d3be5..00000000 --- a/tests/ExcelMcp.Core.Tests/Commands/ChartCommandsTests.DataSource.cs +++ /dev/null @@ -1,157 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands.Chart; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands; - -/// -/// Integration tests for Chart data source operations (SetSourceRange, AddSeries, RemoveSeries). -/// -public partial class ChartCommandsTests -{ - [Fact] - public void SetSourceRange_RegularChart_UpdatesDataSource() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - // Write additional data range for source change test - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - sheet.Range["E1:F5"].Value2 = new object[,] { - { "New", "Data" }, - { "X", 100 }, - { "Y", 200 }, - { "Z", 300 }, - { "W", 400 } - }; - return 0; - }); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.ColumnClustered, 50, 50); - - // Act - _commands.SetSourceRange(batch, createResult.ChartName, "E1:F5"); - - // Assert - Verify source range changed (Excel returns SERIES formula with Sheet1 reference) - var readResult = _commands.Read(batch, createResult.ChartName); - Assert.Contains("Sheet1", readResult.SourceRange, StringComparison.OrdinalIgnoreCase); - Assert.Contains("$E$", readResult.SourceRange, StringComparison.OrdinalIgnoreCase); - Assert.Contains("$F$", readResult.SourceRange, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void AddSeries_RegularChart_AddsNewSeries() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.Line, 50, 50); - - var readBefore = _commands.Read(batch, createResult.ChartName); - int initialSeriesCount = readBefore.Series.Count; - - // Act - var addSeriesResult = _commands.AddSeries( - batch, - createResult.ChartName, - "NewSeries", - "Sheet1!C2:C4", - "Sheet1!A2:A4"); - - // Assert - Assert.Equal("NewSeries", addSeriesResult.Name); - - // Verify series added - var readAfter = _commands.Read(batch, createResult.ChartName); - Assert.Equal(initialSeriesCount + 1, readAfter.Series.Count); - Assert.Contains(readAfter.Series, s => s.Name == "NewSeries"); - } - - [Fact] - public void RemoveSeries_RegularChart_RemovesSeries() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:C4", ChartType.ColumnClustered, 50, 50); - - var readBefore = _commands.Read(batch, createResult.ChartName); - int initialSeriesCount = readBefore.Series.Count; - Assert.True(initialSeriesCount >= 2, "Need at least 2 series for test"); - - // Act - Remove first series (index 1) - _commands.RemoveSeries(batch, createResult.ChartName, 1); - - // Assert - Verify series removed - var readAfter = _commands.Read(batch, createResult.ChartName); - Assert.Equal(initialSeriesCount - 1, readAfter.Series.Count); - } - - [Fact] - public void AddSeries_WithoutCategoryRange_CreatesSeriesSuccessfully() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.XYScatter, 50, 50); - - // Act - Add series without category range - var addResult = _commands.AddSeries(batch, createResult.ChartName, "Series3", "Sheet1!C2:C4", null); - - // Assert - Assert.Equal("Series3", addResult.Name); - } - - [Fact] - public void SetSourceRange_NonExistentChart_ReturnsError() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - // Act & Assert - var exception = Assert.Throws(() => - _commands.SetSourceRange(batch, "NonExistent", "A1:B10")); - Assert.Contains("not found", exception.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void AddSeries_InvalidSeriesIndex_HandlesGracefully() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.Line, 50, 50); - - // Act & Assert - Invalid series index should throw exception - var exception = Assert.Throws(() => - _commands.RemoveSeries(batch, createResult.ChartName, 999)); - - Assert.NotNull(exception); - } - - [Fact] - public void SetSourceRange_ExpandedRange_UpdatesChartData() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B3", ChartType.BarClustered, 50, 50); - - // Act - Expand to include more rows - _commands.SetSourceRange(batch, createResult.ChartName, "A1:B5"); - - // Assert - Verify expanded range (Excel returns SERIES formula with Sheet1 reference) - var readResult = _commands.Read(batch, createResult.ChartName); - Assert.Contains("Sheet1", readResult.SourceRange, StringComparison.OrdinalIgnoreCase); - Assert.Contains("$A$", readResult.SourceRange, StringComparison.OrdinalIgnoreCase); - // Verify it includes row 5 (expanded from 3 to 5) - Assert.Contains("$5", readResult.SourceRange, StringComparison.OrdinalIgnoreCase); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Commands/ChartCommandsTests.Formatting.cs b/tests/ExcelMcp.Core.Tests/Commands/ChartCommandsTests.Formatting.cs deleted file mode 100644 index ca942997..00000000 --- a/tests/ExcelMcp.Core.Tests/Commands/ChartCommandsTests.Formatting.cs +++ /dev/null @@ -1,491 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands.Chart; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands; - -/// -/// Integration tests for Chart formatting operations (DataLabels, AxisScale, Gridlines, SeriesFormat). -/// -public partial class ChartCommandsTests -{ - // === DATA LABELS === - - [Fact] - [Trait("Feature", "Charts")] - public void SetDataLabels_ShowValue_DisplaysValuesOnChart() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.ColumnClustered, 50, 50); - - // Act - Enable data labels showing values - _commands.SetDataLabels(batch, createResult.ChartName, showValue: true); - - // Assert - Verify data labels are set (no exception means success for void operations) - // The operation completed without error - } - - [Fact] - [Trait("Feature", "Charts")] - public void SetDataLabels_ShowPercentage_DisplaysPercentageOnPieChart() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.Pie, 50, 50); - - // Act - Enable percentage labels (common for pie charts) - _commands.SetDataLabels(batch, createResult.ChartName, showPercentage: true); - - // Assert - Operation succeeded - } - - [Fact] - [Trait("Feature", "Charts")] - public void SetDataLabels_SpecificSeries_AppliesOnlyToTargetSeries() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:C4", ChartType.Line, 50, 50); - - // Act - Enable data labels only for series 1 - _commands.SetDataLabels(batch, createResult.ChartName, showValue: true, seriesIndex: 1); - - // Assert - Operation succeeded - } - - [Fact] - [Trait("Feature", "Charts")] - public void SetDataLabels_WithPosition_SetsLabelPosition() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.ColumnClustered, 50, 50); - - // Act - Show values at outside end of bars - _commands.SetDataLabels(batch, createResult.ChartName, showValue: true, labelPosition: DataLabelPosition.OutsideEnd); - - // Assert - Operation succeeded - } - - /// - /// Regression test: SetDataLabels with InsideEnd/InsideBase/OutsideEnd on Line charts - /// must throw a friendly InvalidOperationException, not a raw COMException. - /// These positions are only valid for bar/column/area chart types. - /// - [Fact] - [Trait("Feature", "Charts")] - public void SetDataLabels_InsideEndOnLineChart_ThrowsFriendlyException() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.Line, 50, 50); - - // Act & Assert - must throw InvalidOperationException (not COMException) - var ex = Assert.Throws(() => - _commands.SetDataLabels(batch, createResult.ChartName, showValue: true, labelPosition: DataLabelPosition.InsideEnd)); - - Assert.Contains("InsideEnd", ex.Message); - Assert.Contains("not supported", ex.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - [Trait("Feature", "Charts")] - public void SetDataLabels_AboveOnLineChart_Succeeds() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.Line, 50, 50); - - // Act - Above is valid for line charts - _commands.SetDataLabels(batch, createResult.ChartName, showValue: true, labelPosition: DataLabelPosition.Above); - - // Assert - Operation succeeded without exception - } - - // === AXIS SCALE === - - [Fact] - [Trait("Feature", "Charts")] - public void GetAxisScale_ValueAxis_ReturnsScaleInfo() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.Line, 50, 50); - - // Act - var result = _commands.GetAxisScale(batch, createResult.ChartName, ChartAxisType.Value); - - // Assert - Assert.True(result.Success); - Assert.Equal(createResult.ChartName, result.ChartName); - Assert.Equal("Value", result.AxisType); - // By default, Excel uses auto scale - Assert.True(result.MinimumScaleIsAuto); - Assert.True(result.MaximumScaleIsAuto); - } - - [Fact] - [Trait("Feature", "Charts")] - public void SetAxisScale_CustomMinMax_SetsScaleValues() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.Line, 50, 50); - - // Act - Set custom scale - _commands.SetAxisScale(batch, createResult.ChartName, ChartAxisType.Value, minimumScale: 0, maximumScale: 500); - - // Assert - Verify scale changed - var result = _commands.GetAxisScale(batch, createResult.ChartName, ChartAxisType.Value); - Assert.False(result.MinimumScaleIsAuto); - Assert.False(result.MaximumScaleIsAuto); - Assert.Equal(0, result.MinimumScale); - Assert.Equal(500, result.MaximumScale); - } - - [Fact] - [Trait("Feature", "Charts")] - public void SetAxisScale_WithMajorUnit_SetsMajorUnitInterval() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.ColumnClustered, 50, 50); - - // Act - Set major unit to 50 - _commands.SetAxisScale(batch, createResult.ChartName, ChartAxisType.Value, majorUnit: 50); - - // Assert - Verify major unit changed - var result = _commands.GetAxisScale(batch, createResult.ChartName, ChartAxisType.Value); - Assert.False(result.MajorUnitIsAuto); - Assert.Equal(50, result.MajorUnit); - } - - // === GRIDLINES === - - [Fact] - [Trait("Feature", "Charts")] - public void GetGridlines_Chart_ReturnsGridlinesInfo() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.ColumnClustered, 50, 50); - - // Act - var result = _commands.GetGridlines(batch, createResult.ChartName); - - // Assert - Assert.True(result.Success); - Assert.Equal(createResult.ChartName, result.ChartName); - // Default Excel charts have major gridlines on value axis - Assert.True(result.Gridlines.HasValueMajorGridlines); - } - - [Fact] - [Trait("Feature", "Charts")] - public void SetGridlines_EnableMinorGridlines_ShowsMinorGridlines() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.Line, 50, 50); - - // Act - Enable minor gridlines on value axis - _commands.SetGridlines(batch, createResult.ChartName, ChartAxisType.Value, showMinor: true); - - // Assert - var result = _commands.GetGridlines(batch, createResult.ChartName); - Assert.True(result.Gridlines.HasValueMinorGridlines); - } - - [Fact] - [Trait("Feature", "Charts")] - public void SetGridlines_DisableMajorGridlines_HidesMajorGridlines() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.ColumnClustered, 50, 50); - - // Act - Hide major gridlines on value axis - _commands.SetGridlines(batch, createResult.ChartName, ChartAxisType.Value, showMajor: false); - - // Assert - var result = _commands.GetGridlines(batch, createResult.ChartName); - Assert.False(result.Gridlines.HasValueMajorGridlines); - } - - // === SERIES FORMATTING === - - [Fact] - [Trait("Feature", "Charts")] - public void SetSeriesFormat_MarkerStyle_ChangesMarkerStyle() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - // Use LineMarkers chart type which shows markers by default - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.LineMarkers, 50, 50); - - // Act - Change marker style to diamond - _commands.SetSeriesFormat(batch, createResult.ChartName, seriesIndex: 1, markerStyle: MarkerStyle.Diamond); - - // Assert - Operation succeeded (void operation) - } - - [Fact] - [Trait("Feature", "Charts")] - public void SetSeriesFormat_MarkerSize_ChangesMarkerSize() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.XYScatter, 50, 50); - - // Act - Set marker size to 10 - _commands.SetSeriesFormat(batch, createResult.ChartName, seriesIndex: 1, markerSize: 10); - - // Assert - Operation succeeded - } - - [Fact] - [Trait("Feature", "Charts")] - public void SetSeriesFormat_MarkerColors_SetsMarkerColors() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.LineMarkers, 50, 50); - - // Act - Set marker colors (red fill, blue border) - _commands.SetSeriesFormat( - batch, - createResult.ChartName, - seriesIndex: 1, - markerBackgroundColor: "#FF0000", - markerForegroundColor: "#0000FF"); - - // Assert - Operation succeeded - } - - [Fact] - [Trait("Feature", "Charts")] - public void SetSeriesFormat_InvalidSeriesIndex_ThrowsException() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.Line, 50, 50); - - // Act & Assert - Should throw for invalid series index - Assert.Throws(() => - _commands.SetSeriesFormat(batch, createResult.ChartName, seriesIndex: 999, markerStyle: MarkerStyle.Circle)); - } - - [Fact] - [Trait("Feature", "Charts")] - public void SetSeriesFormat_InvalidMarkerSize_ThrowsException() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.LineMarkers, 50, 50); - - // Act & Assert - Should throw for marker size outside valid range (2-72) - Assert.Throws(() => - _commands.SetSeriesFormat(batch, createResult.ChartName, seriesIndex: 1, markerSize: 100)); - } - - [Fact] - [Trait("Feature", "Charts")] - public void SetDataLabels_InvalidSeriesIndex_ThrowsException() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.ColumnClustered, 50, 50); - - // Act & Assert - Should throw for invalid series index - Assert.Throws(() => - _commands.SetDataLabels(batch, createResult.ChartName, showValue: true, seriesIndex: 999)); - } - - // === TRENDLINES === - - [Fact] - [Trait("Feature", "Charts")] - public void AddTrendline_Linear_AddsTrendlineToSeries() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B5", ChartType.XYScatter, 50, 50); - - // Act - var result = _commands.AddTrendline(batch, createResult.ChartName, seriesIndex: 1, trendlineType: TrendlineType.Linear); - - // Assert - Assert.True(result.Success, $"AddTrendline failed: {result.ErrorMessage}"); - Assert.Equal(TrendlineType.Linear, result.Type); - Assert.Equal(1, result.TrendlineIndex); - } - - [Fact] - [Trait("Feature", "Charts")] - public void AddTrendline_WithEquationDisplay_ShowsEquationOnChart() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B5", ChartType.XYScatter, 50, 50); - - // Act - var result = _commands.AddTrendline(batch, createResult.ChartName, seriesIndex: 1, trendlineType: TrendlineType.Linear, - displayEquation: true, displayRSquared: true); - - // Assert - Assert.True(result.Success); - - // Verify via ListTrendlines - var listResult = _commands.ListTrendlines(batch, createResult.ChartName, seriesIndex: 1); - Assert.True(listResult.Success); - Assert.Single(listResult.Trendlines); - Assert.True(listResult.Trendlines[0].DisplayEquation); - Assert.True(listResult.Trendlines[0].DisplayRSquared); - } - - [Fact] - [Trait("Feature", "Charts")] - public void AddTrendline_Polynomial_RequiresOrder() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B5", ChartType.XYScatter, 50, 50); - - // Act & Assert - Should throw without order - Assert.Throws(() => - _commands.AddTrendline(batch, createResult.ChartName, seriesIndex: 1, trendlineType: TrendlineType.Polynomial)); - - // Should succeed with order - var result = _commands.AddTrendline(batch, createResult.ChartName, seriesIndex: 1, trendlineType: TrendlineType.Polynomial, order: 2); - Assert.True(result.Success); - } - - [Fact] - [Trait("Feature", "Charts")] - public void ListTrendlines_MultipleTrendlines_ReturnsAll() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B5", ChartType.XYScatter, 50, 50); - - // Add multiple trendlines - _commands.AddTrendline(batch, createResult.ChartName, seriesIndex: 1, trendlineType: TrendlineType.Linear); - _commands.AddTrendline(batch, createResult.ChartName, seriesIndex: 1, trendlineType: TrendlineType.Exponential); - - // Act - var result = _commands.ListTrendlines(batch, createResult.ChartName, seriesIndex: 1); - - // Assert - Assert.True(result.Success); - Assert.Equal(2, result.Trendlines.Count); - Assert.Contains(result.Trendlines, t => t.Type == TrendlineType.Linear); - Assert.Contains(result.Trendlines, t => t.Type == TrendlineType.Exponential); - } - - [Fact] - [Trait("Feature", "Charts")] - public void DeleteTrendline_RemovesTrendlineFromSeries() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B5", ChartType.XYScatter, 50, 50); - _commands.AddTrendline(batch, createResult.ChartName, seriesIndex: 1, trendlineType: TrendlineType.Linear); - - // Verify trendline exists - var beforeList = _commands.ListTrendlines(batch, createResult.ChartName, seriesIndex: 1); - Assert.Single(beforeList.Trendlines); - - // Act - _commands.DeleteTrendline(batch, createResult.ChartName, seriesIndex: 1, trendlineIndex: 1); - - // Assert - var afterList = _commands.ListTrendlines(batch, createResult.ChartName, seriesIndex: 1); - Assert.Empty(afterList.Trendlines); - } - - [Fact] - [Trait("Feature", "Charts")] - public void SetTrendline_UpdatesDisplayOptions() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B5", ChartType.XYScatter, 50, 50); - _commands.AddTrendline(batch, createResult.ChartName, seriesIndex: 1, trendlineType: TrendlineType.Linear); - - // Verify initial state (no equation displayed) - var beforeList = _commands.ListTrendlines(batch, createResult.ChartName, seriesIndex: 1); - Assert.False(beforeList.Trendlines[0].DisplayEquation); - - // Act - _commands.SetTrendline(batch, createResult.ChartName, seriesIndex: 1, trendlineIndex: 1, - displayEquation: true, displayRSquared: true); - - // Assert - var afterList = _commands.ListTrendlines(batch, createResult.ChartName, seriesIndex: 1); - Assert.True(afterList.Trendlines[0].DisplayEquation); - Assert.True(afterList.Trendlines[0].DisplayRSquared); - } - - [Fact] - [Trait("Feature", "Charts")] - public void AddTrendline_WithForecasting_ExtendsTrendline() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B5", ChartType.XYScatter, 50, 50); - - // Act - var result = _commands.AddTrendline(batch, createResult.ChartName, seriesIndex: 1, trendlineType: TrendlineType.Linear, - forward: 2.0, backward: 1.0); - - // Assert - Assert.True(result.Success); - - var listResult = _commands.ListTrendlines(batch, createResult.ChartName, seriesIndex: 1); - Assert.Equal(2.0, listResult.Trendlines[0].Forward); - Assert.Equal(1.0, listResult.Trendlines[0].Backward); - } - - [Fact] - [Trait("Feature", "Charts")] - public void DeleteTrendline_InvalidIndex_ThrowsException() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B5", ChartType.XYScatter, 50, 50); - - // Act & Assert - Should throw for invalid trendline index (no trendlines exist) - Assert.Throws(() => - _commands.DeleteTrendline(batch, createResult.ChartName, seriesIndex: 1, trendlineIndex: 1)); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Commands/ChartCommandsTests.Lifecycle.cs b/tests/ExcelMcp.Core.Tests/Commands/ChartCommandsTests.Lifecycle.cs deleted file mode 100644 index fee09112..00000000 --- a/tests/ExcelMcp.Core.Tests/Commands/ChartCommandsTests.Lifecycle.cs +++ /dev/null @@ -1,446 +0,0 @@ -using Excel = Microsoft.Office.Interop.Excel; -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands.Chart; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands; - -/// -/// Integration tests for Chart lifecycle operations (List, Read, CreateFromRange, CreateFromPivotTable, Delete, Move). -/// -[Trait("Category", "Integration")] -[Trait("Speed", "Medium")] -[Trait("Layer", "Core")] -[Trait("Feature", "Charts")] -[Trait("RequiresExcel", "true")] -public partial class ChartCommandsTests : IClassFixture -{ - private readonly ChartCommands _commands; - private readonly ChartTestsFixture _fixture; - - public ChartCommandsTests(ChartTestsFixture fixture) - { - _commands = new ChartCommands(); - _fixture = fixture; - } - - [Fact] - public void List_EmptyWorkbook_ReturnsEmptyList() - { - // Act - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - var charts = _commands.List(batch); - - // Assert - Assert.Empty(charts); - } - - [Fact] - public void CreateFromRange_ValidData_CreatesChart() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - // Act - var createResult = _commands.CreateFromRange( - batch, - "Sheet1", - "A1:B4", - ChartType.ColumnClustered, - 100, - 50, - 400, - 300, - "TestChart"); - - // Assert - Assert.Equal("TestChart", createResult.ChartName); - Assert.Equal("Sheet1", createResult.SheetName); - Assert.Equal(ChartType.ColumnClustered, createResult.ChartType); - Assert.False(createResult.IsPivotChart); - - // Verify chart exists - var charts = _commands.List(batch); - Assert.Single(charts); - Assert.Equal("TestChart", charts[0].Name); - } - - [Fact] - public void CreateFromTable_ValidTable_CreatesChart() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - // Create test data and table - batch.Execute((ctx, ct) => - { - dynamic? sheet = null; - dynamic? listObjects = null; - dynamic? table = null; - - try - { - sheet = ctx.Book.Worksheets[1]; - - // Set up data - sheet.Range["A1:B4"].Value2 = new object[,] { - { "Category", "Values" }, - { "Q1", 100 }, - { "Q2", 150 }, - { "Q3", 200 } - }; - - // Create table from range - listObjects = sheet.ListObjects; - table = listObjects.Add(1, sheet.Range["A1:B4"], null, 1); // xlYes = 1 - table.Name = "SalesTable"; - - return 0; - } - finally - { - ComUtilities.Release(ref table); - ComUtilities.Release(ref listObjects); - ComUtilities.Release(ref sheet); - } - }); - - // Act - var createResult = _commands.CreateFromTable( - batch, - "SalesTable", - "Sheet1", - ChartType.ColumnClustered, - 100, - 100, - 400, - 300, - "TableChart"); - - // Assert - Assert.True(createResult.Success, $"CreateFromTable failed: {createResult.ChartName}"); - Assert.Equal("TableChart", createResult.ChartName); - Assert.Equal("Sheet1", createResult.SheetName); - Assert.Equal(ChartType.ColumnClustered, createResult.ChartType); - Assert.False(createResult.IsPivotChart); - - // Verify chart exists - var charts = _commands.List(batch); - Assert.Single(charts); - Assert.Equal("TableChart", charts[0].Name); - } - - [Fact] - public void CreateFromTable_NonExistentTable_ThrowsException() - { - // Act & Assert - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - var exception = Assert.Throws(() => - _commands.CreateFromTable( - batch, - "NonExistentTable", - "Sheet1", - ChartType.ColumnClustered, - 50, - 50)); - - Assert.Contains("not found", exception.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void CreateFromPivotTable_RangePivotTable_CreatesPivotChart() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - // Create data and PivotTable - string pivotTableName = "TestPivot"; - batch.Execute((ctx, ct) => - { - dynamic? sheet = null; - dynamic? dataRange = null; - dynamic? pivotCache = null; - dynamic? newSheet = null; - dynamic? pivot = null; - dynamic? rowField = null; - dynamic? dataField = null; - - try - { - sheet = ctx.Book.Worksheets.Item[1]; - - // Create sample data - sheet.Range["A1:C5"].Value2 = new object[,] { - { "Product", "Region", "Sales" }, - { "Widget", "North", 100 }, - { "Widget", "South", 150 }, - { "Gadget", "North", 200 }, - { "Gadget", "South", 250 } - }; - - // Create PivotTable - dataRange = sheet.Range["A1:C5"]; - pivotCache = ctx.Book.PivotCaches().Create(Excel.XlPivotTableSourceType.xlDatabase, dataRange); - newSheet = ctx.Book.Worksheets.Add(); - newSheet.Name = "PivotSheet"; - pivot = pivotCache.CreatePivotTable(newSheet.Range["A1"], pivotTableName); - - // Add fields - rowField = pivot.PivotFields("Product"); - rowField.Orientation = 1; // xlRowField - - dataField = pivot.PivotFields("Sales"); - dataField.Orientation = 4; // xlDataField - - return 0; - } - finally - { - ComUtilities.Release(ref dataField); - ComUtilities.Release(ref rowField); - ComUtilities.Release(ref pivot); - ComUtilities.Release(ref newSheet); - ComUtilities.Release(ref pivotCache); - ComUtilities.Release(ref dataRange); - ComUtilities.Release(ref sheet); - } - }); - - // Act - var result = _commands.CreateFromPivotTable( - batch, - pivotTableName, - "PivotSheet", - ChartType.ColumnClustered, - 300, - 50, - 400, - 300, - "PivotChart1"); - - // Assert - Assert.True(result.IsPivotChart, "Chart should be marked as PivotChart"); - Assert.Equal(pivotTableName, result.LinkedPivotTable); - Assert.Equal("PivotSheet", result.SheetName); - Assert.Equal(ChartType.ColumnClustered, result.ChartType); - - // Verify chart exists in list - var charts = _commands.List(batch); - Assert.Contains(charts, c => c.Name == result.ChartName && c.IsPivotChart); - } - - [Fact] - public void CreateFromPivotTable_NonExistentPivotTable_ThrowsException() - { - // Act & Assert - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - var exception = Assert.Throws(() => - _commands.CreateFromPivotTable( - batch, - "NonExistentPivot", - "Sheet1", - ChartType.ColumnClustered, - 50, - 50)); - - Assert.Contains("not found", exception.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void CreateFromPivotTable_DifferentChartTypes_CreatesCorrectType() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - // Create data and PivotTable - string pivotTableName = "ChartTypePivot"; - batch.Execute((ctx, ct) => - { - dynamic? sheet = null; - dynamic? dataRange = null; - dynamic? pivotCache = null; - dynamic? newSheet = null; - dynamic? pivot = null; - dynamic? rowField = null; - dynamic? dataField = null; - - try - { - sheet = ctx.Book.Worksheets.Item[1]; - - // Create sample data - sheet.Range["A1:B4"].Value2 = new object[,] { - { "Category", "Value" }, - { "A", 10 }, - { "B", 20 }, - { "C", 30 } - }; - - // Create PivotTable - dataRange = sheet.Range["A1:B4"]; - pivotCache = ctx.Book.PivotCaches().Create(Excel.XlPivotTableSourceType.xlDatabase, dataRange); - newSheet = ctx.Book.Worksheets.Add(); - newSheet.Name = "PivotSheet2"; - pivot = pivotCache.CreatePivotTable(newSheet.Range["A1"], pivotTableName); - - // Add fields - rowField = pivot.PivotFields("Category"); - rowField.Orientation = 1; // xlRowField - - dataField = pivot.PivotFields("Value"); - dataField.Orientation = 4; // xlDataField - - return 0; - } - finally - { - ComUtilities.Release(ref dataField); - ComUtilities.Release(ref rowField); - ComUtilities.Release(ref pivot); - ComUtilities.Release(ref newSheet); - ComUtilities.Release(ref pivotCache); - ComUtilities.Release(ref dataRange); - ComUtilities.Release(ref sheet); - } - }); - - // Act - Create Pie chart - var result = _commands.CreateFromPivotTable( - batch, - pivotTableName, - "PivotSheet2", - ChartType.Pie, - 300, - 50, - 300, - 300); - - // Assert - Assert.Equal(ChartType.Pie, result.ChartType); - Assert.True(result.IsPivotChart); - } - - [Fact] - public void Read_ExistingChart_ReturnsDetails() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.Pie, 50, 50, 300, 300, "PieChart"); - - // Act - var readResult = _commands.Read(batch, "PieChart"); - - // Assert - Assert.Equal("PieChart", readResult.Name); - Assert.Equal("Sheet1", readResult.SheetName); - Assert.Equal(ChartType.Pie, readResult.ChartType); - Assert.False(readResult.IsPivotChart); - Assert.True(readResult.Series.Count > 0); - } - - [Fact] - public void Read_NonExistentChart_ReturnsError() - { - // Act & Assert - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - var exception = Assert.Throws(() => _commands.Read(batch, "NonExistent")); - Assert.Contains("not found", exception.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void Delete_ExistingChart_RemovesChart() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - _commands.CreateFromRange(batch, "Sheet1", "A1:B3", ChartType.Line, 50, 50); - - var chartsBefore = _commands.List(batch); - Assert.Single(chartsBefore); - string chartName = chartsBefore[0].Name; - - // Act - _commands.Delete(batch, chartName); - - // Assert - Verify chart removed - var chartsAfter = _commands.List(batch); - Assert.Empty(chartsAfter); - } - - [Fact] - public void Move_ExistingChart_UpdatesPosition() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - var createResult = _commands.CreateFromRange(batch, "Sheet1", "A1:B3", ChartType.ColumnClustered, 100, 100, 300, 200); - - // Act - _commands.Move(batch, createResult.ChartName, left: 200, top: 150, width: 400, height: 250); - - // Assert - Verify position updated - var readResult = _commands.Read(batch, createResult.ChartName); - Assert.Equal(200, readResult.Left); - Assert.Equal(150, readResult.Top); - Assert.Equal(400, readResult.Width); - Assert.Equal(250, readResult.Height); - } - - [Fact] - public void List_MultipleCharts_ReturnsAll() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - // Create multiple charts - _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.ColumnClustered, 50, 50, 300, 200, "Chart1"); - _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.Pie, 400, 50, 300, 200, "Chart2"); - _commands.CreateFromRange(batch, "Sheet1", "A1:B4", ChartType.Line, 50, 300, 300, 200, "Chart3"); - - // Act - var charts = _commands.List(batch); - - // Assert - Assert.Equal(3, charts.Count); - Assert.Contains(charts, c => c.Name == "Chart1" && c.ChartType == ChartType.ColumnClustered); - Assert.Contains(charts, c => c.Name == "Chart2" && c.ChartType == ChartType.Pie); - Assert.Contains(charts, c => c.Name == "Chart3" && c.ChartType == ChartType.Line); - } - - [Fact] - public void CreateFromRange_DifferentChartTypes_CreatesCorrectly() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.SharedTestFile); - - // Act & Assert - Test various chart types - var chartTypes = new[] - { - ChartType.ColumnClustered, - ChartType.BarClustered, - ChartType.Line, - ChartType.Pie, - ChartType.XYScatter, - ChartType.Area - }; - - int x = 50; - foreach (var chartType in chartTypes) - { - var result = _commands.CreateFromRange(batch, "Sheet1", "A1:C5", chartType, x, 50, 250, 200); - Assert.Equal(chartType, result.ChartType); - x += 300; - } - - // Verify all created - var charts = _commands.List(batch); - Assert.Equal(chartTypes.Length, charts.Count); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Commands/ChartCommandsTests.OlapPivotChart.cs b/tests/ExcelMcp.Core.Tests/Commands/ChartCommandsTests.OlapPivotChart.cs deleted file mode 100644 index 0089904d..00000000 --- a/tests/ExcelMcp.Core.Tests/Commands/ChartCommandsTests.OlapPivotChart.cs +++ /dev/null @@ -1,231 +0,0 @@ -// Copyright (c) Sbroenne. All rights reserved. -// Licensed under the MIT License. - -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands.Chart; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands; - -/// -/// Integration tests for creating charts from OLAP (Data Model) PivotTables. -/// These tests use which creates a workbook -/// with Power Pivot Data Model, DAX measures, and OLAP-based PivotTables. -/// -/// This tests the OLAP-specific chart creation path which uses Shapes.AddChart() + SetSourceData() -/// instead of PivotCache.CreatePivotChart() (which fails for OLAP sources). -/// -[Collection("DataModel")] -[Trait("Category", "Integration")] -[Trait("Speed", "Slow")] -[Trait("Layer", "Core")] -[Trait("Feature", "Charts")] -[Trait("RequiresExcel", "true")] -public class ChartCommandsOlapTests -{ - private readonly ChartCommands _commands; - private readonly DataModelPivotTableFixture _fixture; - - public ChartCommandsOlapTests(DataModelPivotTableFixture fixture) - { - _commands = new ChartCommands(); - _fixture = fixture; - } - - [Fact] - public void CreateFromPivotTable_OlapDataModelPivot_CreatesPivotChart() - { - // Arrange - Use the Data Model PivotTable from fixture - // The fixture creates "DataModelPivot" PivotTable on sheet "ModelData" - string pivotTableName = "DataModelPivot"; - string sheetName = "ModelData"; - - // Act - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var result = _commands.CreateFromPivotTable( - batch, - pivotTableName, - sheetName, - ChartType.ColumnClustered, - 300, - 200, - 400, - 300, - "OlapChart1"); - - // Assert - Assert.True(result.IsPivotChart, "Chart should be marked as PivotChart"); - Assert.Equal(pivotTableName, result.LinkedPivotTable); - Assert.Equal(sheetName, result.SheetName); - Assert.Equal(ChartType.ColumnClustered, result.ChartType); - Assert.NotNull(result.ChartName); - - // Verify chart exists in list - var charts = _commands.List(batch); - Assert.Contains(charts, c => c.Name == result.ChartName); - } - - [Fact] - public void CreateFromPivotTable_OlapPivotWithDaxMeasures_CreatesPivotChart() - { - // Arrange - Use the Data Model PivotTable that includes DAX measures - // DataModelPivot has measures like "Total Sales", "Total Revenue", etc. - string pivotTableName = "DataModelPivot"; - string sheetName = "ModelData"; - - // Act - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var result = _commands.CreateFromPivotTable( - batch, - pivotTableName, - sheetName, - ChartType.Pie, - 300, - 400, - 350, - 350, - "OlapPieChart"); - - // Assert - Assert.True(result.IsPivotChart); - Assert.Equal(ChartType.Pie, result.ChartType); - - // Verify chart was created - var chartInfo = _commands.Read(batch, result.ChartName); - Assert.Equal("OlapPieChart", chartInfo.Name); - Assert.Equal(sheetName, chartInfo.SheetName); - } - - [Fact] - public void CreateFromPivotTable_OlapPivot_BarChart_CreatesCorrectType() - { - // Arrange - string pivotTableName = "DataModelPivot"; - string sheetName = "ModelData"; - - // Act - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var result = _commands.CreateFromPivotTable( - batch, - pivotTableName, - sheetName, - ChartType.BarClustered, - 50, - 500, - 400, - 300, - "OlapBarChart"); - - // Assert - Assert.True(result.IsPivotChart); - Assert.Equal(ChartType.BarClustered, result.ChartType); - - // Verify via Read - var chartInfo = _commands.Read(batch, result.ChartName); - Assert.Equal(ChartType.BarClustered, chartInfo.ChartType); - } - - [Fact] - public void CreateFromPivotTable_OlapPivot_LineChart_CreatesCorrectType() - { - // Arrange - string pivotTableName = "DataModelPivot"; - string sheetName = "ModelData"; - - // Act - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var result = _commands.CreateFromPivotTable( - batch, - pivotTableName, - sheetName, - ChartType.Line, - 50, - 700, - 400, - 300, - "OlapLineChart"); - - // Assert - Assert.True(result.IsPivotChart); - Assert.Equal(ChartType.Line, result.ChartType); - - // Verify chart appears in list - var charts = _commands.List(batch); - Assert.Contains(charts, c => c.Name == "OlapLineChart" && c.ChartType == ChartType.Line); - } - - [Fact] - public void CreateFromPivotTable_DisambiguationTestPivot_CreatesPivotChart() - { - // Arrange - Use the second OLAP PivotTable created by fixture - // "DisambiguationTest" is on sheet "DisambiguationPivot" - string pivotTableName = "DisambiguationTest"; - string sheetName = "DisambiguationPivot"; - - // Act - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var result = _commands.CreateFromPivotTable( - batch, - pivotTableName, - sheetName, - ChartType.ColumnClustered, - 50, - 200, - 400, - 300, - "DisambiguationChart"); - - // Assert - Assert.True(result.IsPivotChart); - Assert.Equal(pivotTableName, result.LinkedPivotTable); - Assert.Equal(sheetName, result.SheetName); - - // Verify chart exists - var charts = _commands.List(batch); - Assert.Contains(charts, c => c.Name == "DisambiguationChart"); - } - - [Fact] - public void CreateFromPivotTable_OlapPivot_CustomPositionAndSize_AppliesCorrectly() - { - // Arrange - string pivotTableName = "DataModelPivot"; - string sheetName = "ModelData"; - - double expectedLeft = 150; - double expectedTop = 100; - double expectedWidth = 500; - double expectedHeight = 400; - - // Act - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var result = _commands.CreateFromPivotTable( - batch, - pivotTableName, - sheetName, - ChartType.ColumnClustered, - expectedLeft, - expectedTop, - expectedWidth, - expectedHeight, - "PositionedOlapChart"); - - // Assert - Assert.Equal(expectedLeft, result.Left); - Assert.Equal(expectedTop, result.Top); - Assert.Equal(expectedWidth, result.Width); - Assert.Equal(expectedHeight, result.Height); - - // Verify via Read - var chartInfo = _commands.Read(batch, result.ChartName); - Assert.Equal(expectedLeft, chartInfo.Left); - Assert.Equal(expectedTop, chartInfo.Top); - Assert.Equal(expectedWidth, chartInfo.Width); - Assert.Equal(expectedHeight, chartInfo.Height); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Helpers/AceOleDbTestHelper.cs b/tests/ExcelMcp.Core.Tests/Helpers/AceOleDbTestHelper.cs deleted file mode 100644 index b7724ea7..00000000 --- a/tests/ExcelMcp.Core.Tests/Helpers/AceOleDbTestHelper.cs +++ /dev/null @@ -1,68 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; - -namespace Sbroenne.ExcelMcp.Core.Tests.Helpers; - -/// -/// Helper functions for creating temporary Excel data sources consumable via the -/// Microsoft.ACE.OLEDB provider. These utilities are used by OLEDB integration tests. -/// -public static class AceOleDbTestHelper -{ - private const string ProviderName = "Microsoft.ACE.OLEDB.16.0"; - - /// - /// Creates a temporary Excel workbook containing a simple Products table that can - /// be queried via ACE OLEDB. The workbook is saved to . - /// - public static void CreateExcelDataSource(string workbookPath) - { - ExcelSession.CreateNew(workbookPath, isMacroEnabled: false, (ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - sheet.Name = "Products"; - sheet.Range["A1"].Value2 = "Product"; - sheet.Range["B1"].Value2 = "Price"; - sheet.Range["A2"].Value2 = "Widget"; - sheet.Range["B2"].Value2 = 19.99; - sheet.Range["A3"].Value2 = "Gadget"; - sheet.Range["B3"].Value2 = 29.99; - - ctx.Book.Save(); - return 0; - }); - } - - /// - /// Updates the value cells in the Excel data source using an Excel COM operation. - /// Useful for simulating data source changes before calling Refresh on the connection. - /// - public static void UpdateExcelDataSource(string workbookPath, Action updateAction) - { - using var batch = ExcelSession.BeginBatch(workbookPath); - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - updateAction(sheet); - ctx.Book.Save(); - return 0; - }); - } - - /// - /// Generates an ACE OLEDB connection string targeting the supplied Excel workbook. - /// - public static string GetExcelConnectionString(string workbookPath, bool headersPresent = true) - { - string hdr = headersPresent ? "YES" : "NO"; - return $"OLEDB;Provider={ProviderName};Data Source={workbookPath};Extended Properties=\"Excel 12.0 Xml;HDR={hdr}\""; - } - - /// - /// Returns the default SQL command text used in tests to select data from the Products worksheet. - /// - public static string GetDefaultCommandText() => "SELECT * FROM [Products$]"; -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Helpers/ChartTestsFixture.cs b/tests/ExcelMcp.Core.Tests/Helpers/ChartTestsFixture.cs deleted file mode 100644 index 061ee8a1..00000000 --- a/tests/ExcelMcp.Core.Tests/Helpers/ChartTestsFixture.cs +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright (c) Sbroenne. All rights reserved. -// Licensed under the MIT License. - -using System.Runtime.CompilerServices; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Helpers; - -/// -/// Fixture for Chart tests — creates ONE shared workbook with pre-populated data. -/// All tests share this file safely because ExcelBatch.Dispose() closes WITHOUT saving. -/// -/// Performance: Creates 2 COM sessions at startup instead of 2 per test (74 tests = 148→2 sessions saved). -/// Data layout on Sheet1: A1:D6 with 4 columns × 5 data rows (covers most test scenarios). -/// -public class ChartTestsFixture : IAsyncLifetime -{ - private readonly string _tempDir; - - /// - /// Temp directory for all test files (auto-cleaned on disposal) - /// - public string TempDir => _tempDir; - - /// - /// Pre-populated test file shared by all tests. Safe to share because - /// ExcelBatch.Dispose() closes the workbook WITHOUT saving — each test - /// sees the original saved state (data, no charts). - /// - public string SharedTestFile { get; private set; } = null!; - - public ChartTestsFixture() - { - _tempDir = Path.Join(Path.GetTempPath(), $"ChartTests_{Guid.NewGuid():N}"); - Directory.CreateDirectory(_tempDir); - } - - /// - /// Creates a copy of the shared test file for tests that need file isolation. - /// Uses File.Copy (microseconds) instead of COM session (seconds). - /// - public string CreateTestFile([CallerMemberName] string testName = "", string extension = ".xlsx") - { - var fileName = $"{testName}_{Guid.NewGuid():N}{extension}"; - var filePath = Path.Join(_tempDir, fileName); - File.Copy(SharedTestFile, filePath); - return filePath; - } - - /// - /// Called ONCE before any tests run. Creates shared workbook with standard data. - /// - public Task InitializeAsync() - { - SharedTestFile = Path.Join(_tempDir, "SharedChartTestFile.xlsx"); - - // Create file — 1st COM session - using var manager = new SessionManager(); - var sessionId = manager.CreateSessionForNewFile(SharedTestFile, show: false); - manager.CloseSession(sessionId, save: true); - - // Pre-populate standard data — 2nd COM session - using var batch = ExcelSession.BeginBatch(SharedTestFile); - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - // 4 columns × 5 data rows — covers A1:B3, A1:B4, A1:B5, A1:C4, A1:D4 patterns - sheet.Range["A1:D6"].Value2 = new object[,] - { - { "X", "Y", "Series2", "Series3" }, - { 1, 100, 15, 50 }, - { 2, 150, 25, 75 }, - { 3, 200, 35, 100 }, - { 4, 250, 45, 125 }, - { 5, 300, 55, 150 } - }; - return 0; - }); - batch.Save(); - - return Task.CompletedTask; - } - - /// - /// Called ONCE after all tests in the class complete. - /// - public Task DisposeAsync() - { - try - { - if (Directory.Exists(_tempDir)) - { - Directory.Delete(_tempDir, recursive: true); - } - } - catch - { - // Cleanup is best-effort - } - return Task.CompletedTask; - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Helpers/ConnectionTestHelper.cs b/tests/ExcelMcp.Core.Tests/Helpers/ConnectionTestHelper.cs deleted file mode 100644 index 1533b8ba..00000000 --- a/tests/ExcelMcp.Core.Tests/Helpers/ConnectionTestHelper.cs +++ /dev/null @@ -1,107 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; - -namespace Sbroenne.ExcelMcp.Core.Tests.Helpers; - -/// -/// Helper class to create test Excel connections via COM interop. -/// Used by integration and round trip tests. -/// -public static class ConnectionTestHelper -{ - /// - /// Creates a simple OLEDB connection to a test database in an Excel workbook. - /// This creates an actual Excel connection object that can be managed by ConnectionCommands. - /// - public static void CreateAceOleDbConnection(string excelFilePath, string connectionName, string sourceWorkbookPath) - { - var connectionString = AceOleDbTestHelper.GetExcelConnectionString(sourceWorkbookPath); - CreateOleDbConnection( - excelFilePath, - connectionName, - connectionString, - AceOleDbTestHelper.GetDefaultCommandText(), - commandType: 2); - } - - /// - /// Creates a simple OLEDB connection to a test database in an Excel workbook. - /// This creates an actual Excel connection object that can be managed by ConnectionCommands. - /// - public static void CreateOleDbConnection(string filePath, string connectionName, string connectionString, string? commandText = null, int? commandType = null) - { - using var batch = ExcelSession.BeginBatch(filePath); - batch.Execute((ctx, ct) => - { - try - { - // Get connections collection - dynamic connections = ctx.Book.Connections; - - // Create OLEDB connection using Add2() (current method, Add() is deprecated) - // Per instructions: Must use Connections.Add2() for OLEDB/ODBC connections - dynamic newConnection = connections.Add2( - Name: connectionName, - Description: $"Test OLEDB connection created by {nameof(CreateOleDbConnection)}", - ConnectionString: connectionString, - CommandText: commandText ?? string.Empty, - lCmdtype: commandType.HasValue ? commandType.Value : Type.Missing, - CreateModelConnection: false, // Don't create Data Model connection - ImportRelationships: false // Don't import relationships - ); - - // Configure OLEDB connection properties - if (newConnection.Type == 1) // OLEDB - { - dynamic oledb = newConnection.OLEDBConnection; - if (oledb != null) - { - oledb.BackgroundQuery = true; - oledb.RefreshOnFileOpen = false; - oledb.SavePassword = false; - } - } - - ctx.Book.Save(); - return 0; // Success - } - catch (Exception ex) - { - throw new InvalidOperationException($"Failed to create OLEDB connection '{connectionName}': {ex.Message}", ex); - } - }); - } - - /// - /// Creates a simple ODBC connection in an Excel workbook. - /// - public static void CreateOdbcConnection(string filePath, string connectionName, string connectionString) - { - using var batch = ExcelSession.BeginBatch(filePath); - batch.Execute((ctx, ct) => - { - try - { - dynamic connections = ctx.Book.Connections; - - // Create ODBC connection using NAMED parameters (Excel COM requires this) - connections.Add( - Name: connectionName, - Description: $"Test ODBC connection created by {nameof(CreateOdbcConnection)}", - ConnectionString: connectionString, - CommandText: "" - ); - - ctx.Book.Save(); - return 0; // Success - } - catch (Exception ex) - { - throw new InvalidOperationException($"Failed to create ODBC connection '{connectionName}': {ex.Message}", ex); - } - }); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Helpers/ConnectionTestsFixture.cs b/tests/ExcelMcp.Core.Tests/Helpers/ConnectionTestsFixture.cs deleted file mode 100644 index 1d877498..00000000 --- a/tests/ExcelMcp.Core.Tests/Helpers/ConnectionTestsFixture.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Runtime.CompilerServices; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Helpers; - -/// -/// Fixture for Connection tests that provides efficient test file creation. -/// Connection tests often need isolated files because they: -/// - Create/delete connections within a single test -/// - Need external source files for OLEDB connections -/// - Modify connection state -/// -/// This fixture provides: -/// - A shared temp directory (auto-cleaned on disposal) -/// - Fast test file creation method -/// - Helper for creating external source files -/// -public class ConnectionTestsFixture : IAsyncLifetime -{ - private readonly string _tempDir; - - /// - /// Temp directory for all test files (auto-cleaned on disposal) - /// - public string TempDir => _tempDir; - - /// - /// Initializes a new instance of the fixture. - /// - public ConnectionTestsFixture() - { - _tempDir = Path.Join(Path.GetTempPath(), $"ConnectionTests_{Guid.NewGuid():N}"); - Directory.CreateDirectory(_tempDir); - } - - /// - public Task InitializeAsync() - { - // No async initialization needed - return Task.CompletedTask; - } - - /// - public Task DisposeAsync() - { - try - { - if (Directory.Exists(_tempDir)) - { - Directory.Delete(_tempDir, recursive: true); - } - } - catch - { - // Cleanup is best-effort - } - return Task.CompletedTask; - } - - /// - /// Creates a unique empty test file for the calling test. - /// Uses [CallerMemberName] to auto-populate the test name. - /// - /// Auto-populated from caller method name - /// Path to the unique test file - public string CreateTestFile([CallerMemberName] string testName = "") - { - var guid = Guid.NewGuid().ToString("N")[..8]; - var testFile = Path.Join(_tempDir, $"Conn_{testName}_{guid}.xlsx"); - using var manager = new SessionManager(); - var sessionId = manager.CreateSessionForNewFile(testFile, show: false); - manager.CloseSession(sessionId, save: true); - return testFile; - } - - /// - /// Creates a unique source file path (does not create the file). - /// Used for OLEDB source workbooks that are created by test helpers. - /// - /// Optional suffix for the file name - /// Auto-populated from caller method name - /// Path for the source file - public string GetSourceFilePath(string suffix = "Source", [CallerMemberName] string testName = "") - { - return Path.Join(_tempDir, $"{testName}_{suffix}.xlsx"); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Helpers/CoreTestHelper.cs b/tests/ExcelMcp.Core.Tests/Helpers/CoreTestHelper.cs deleted file mode 100644 index b2e1e8c3..00000000 --- a/tests/ExcelMcp.Core.Tests/Helpers/CoreTestHelper.cs +++ /dev/null @@ -1,80 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; - -namespace Sbroenne.ExcelMcp.Core.Tests.Helpers; - -/// -/// Shared helper for Core integration tests. -/// Provides utilities for creating unique test files to ensure test isolation. -/// -public static class CoreTestHelper -{ - /// - /// Creates a unique test file for isolated testing. - /// Supports Excel files (.xlsx, .xlsm) and data files (.csv, .txt, etc.). - /// - /// Name of the test class (e.g., "NamedRangeCommandsTests") - /// Name of the test method (e.g., "Create_WithValidParameter_ReturnsSuccess") - /// Temporary directory where the file will be created - /// File extension including dot (e.g., ".xlsx", ".csv", ".txt") - /// Optional file content. Only used for non-Excel files. For CSV, defaults to sample data. - /// Absolute path to the created file - /// Thrown if Excel file creation fails - /// - /// Usage patterns: - /// - /// // Excel file - /// var excelFile = CoreTestHelper.CreateUniqueTestFile( - /// nameof(MyTests), nameof(MyTest), _tempDir, ".xlsx"); - /// - /// // CSV file with default data - /// var csvFile = CoreTestHelper.CreateUniqueTestFile( - /// nameof(MyTests), nameof(MyTest), _tempDir, ".csv"); - /// - /// // CSV file with custom data - /// var csvFile = CoreTestHelper.CreateUniqueTestFile( - /// nameof(MyTests), nameof(MyTest), _tempDir, ".csv", "Col1,Col2\nA,B"); - /// - /// - public static string CreateUniqueTestFile( - string testClassName, - string testName, - string tempDir, - string extension = ".xlsx", - string? content = null) - { - // Generate unique filename: ClassName_TestName_GUID.{extension} - var fileName = $"{testClassName}_{testName}_{Guid.NewGuid():N}{extension}"; - var filePath = Path.Join(tempDir, fileName); - - // Handle Excel files (.xlsx, .xlsm) - if (extension is ".xlsx" or ".xlsm") - { - try - { - // Use SessionManager to create file and immediately close the session - using var manager = new SessionManager(); - var sessionId = manager.CreateSessionForNewFile(filePath, show: false); - manager.CloseSession(sessionId, save: true); - } - catch (Exception ex) - { - throw new InvalidOperationException( - $"Failed to create test Excel file '{filePath}': {ex.Message}. " + - "Excel may not be installed or the path may be invalid.", ex); - } - } - // Handle data files (.csv, .txt, etc.) - else - { - const string defaultContent = "Name,Value\nSample,123\n"; - var finalContent = content ?? defaultContent; - File.WriteAllText(filePath, finalContent); - } - - return filePath; - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Helpers/DataModelPivotTableFixture.cs b/tests/ExcelMcp.Core.Tests/Helpers/DataModelPivotTableFixture.cs deleted file mode 100644 index f22db710..00000000 --- a/tests/ExcelMcp.Core.Tests/Helpers/DataModelPivotTableFixture.cs +++ /dev/null @@ -1,459 +0,0 @@ -using System.Diagnostics; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands; -using Sbroenne.ExcelMcp.Core.Commands.PivotTable; -using Sbroenne.ExcelMcp.Core.Commands.Range; -using Sbroenne.ExcelMcp.Core.Commands.Table; -using Sbroenne.ExcelMcp.Core.Models; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Helpers; - -/// -/// Unified fixture that creates ONE comprehensive Data Model + PivotTable workbook per test CLASS. -/// Consolidates DataModelTestsFixture and PivotTableRealisticFixture into one fixture. -/// -/// Creates: -/// - Data Model tables with relationships (SalesTable → CustomersTable, SalesTable → ProductsTable) -/// - DAX measures for aggregation -/// - PivotTables from multiple source types (range, table, Data Model) -/// - Disambiguation test data for OLAP field matching tests -/// -/// The fixture initialization IS the test for creation. -/// - Created ONCE before any tests run -/// - Shared READ-ONLY by all tests in the class -/// - Each test gets its own batch (isolation at batch level) -/// -public class DataModelPivotTableFixture : IAsyncLifetime -{ - private readonly string _tempDir; - - /// - /// Path to the test file - /// - public string TestFilePath { get; private set; } = null!; - - /// - /// Results of creation (exposed for validation) - /// - public DataModelPivotTableCreationResult CreationResult { get; private set; } = null!; - - public DataModelPivotTableFixture() - { - _tempDir = Path.Join(Path.GetTempPath(), $"DataModelPivotTableTests_{Guid.NewGuid():N}"); - Directory.CreateDirectory(_tempDir); - } - - /// - /// Called ONCE before any tests in the class run. - /// Creates comprehensive Data Model with relationships, measures, and PivotTables. - /// - public Task InitializeAsync() - { - var sw = Stopwatch.StartNew(); - - TestFilePath = Path.Join(_tempDir, "DataModelPivotTables.xlsx"); - CreationResult = new DataModelPivotTableCreationResult(); - - try - { - using (var manager = new SessionManager()) - { - var sessionId = manager.CreateSessionForNewFile(TestFilePath, show: false); - manager.CloseSession(sessionId, save: true); - } - CreationResult.FileCreated = true; - - using var batch = ExcelSession.BeginBatch(TestFilePath); - - var sheetCommands = new SheetCommands(); - var tableCommands = new TableCommands(); - var dataModelCommands = new DataModelCommands(); - var pivotCommands = new PivotTableCommands(); - - // ======================================== - // PART 1: Data Model Tables with Relationships (from DataModelTestsFixture) - // ======================================== - - // Create SalesTable (main fact table) - CreateSalesTable(batch); - - // Create CustomersTable (dimension) - CreateCustomersTable(batch); - - // Create ProductsTable (dimension) - CreateProductsTable(batch); - - CreationResult.TablesCreated = 3; - - // Add tables to Data Model - tableCommands.AddToDataModel(batch, "SalesTable"); - tableCommands.AddToDataModel(batch, "CustomersTable"); - tableCommands.AddToDataModel(batch, "ProductsTable"); - CreationResult.TablesLoadedToModel = 3; - - // Create relationships - dataModelCommands.CreateRelationship( - batch, - "SalesTable", "CustomerID", - "CustomersTable", "CustomerID", - active: true); - - dataModelCommands.CreateRelationship( - batch, - "SalesTable", "ProductID", - "ProductsTable", "ProductID", - active: true); - - CreationResult.RelationshipsCreated = 2; - - // Create DAX measures on SalesTable - dataModelCommands.CreateMeasure( - batch, - "SalesTable", - "Total Sales", - "SUM(SalesTable[Amount])", - "Total sales amount", - "#,##0.00"); - - dataModelCommands.CreateMeasure( - batch, - "SalesTable", - "Average Sale", - "AVERAGE(SalesTable[Amount])", - "Average sale amount", - "#,##0.00"); - - dataModelCommands.CreateMeasure( - batch, - "SalesTable", - "Total Customers", - "DISTINCTCOUNT(SalesTable[CustomerID])", - "Count of unique customers", - "#,##0"); - - CreationResult.MeasuresCreated = 3; - - // ======================================== - // PART 2: Regional Sales Table + PivotTables (from PivotTableRealisticFixture) - // ======================================== - - // Create RegionalSalesTable for PivotTable tests - CreateRegionalSalesTable(batch); - - // Create DisambiguationTable for OLAP field matching tests - CreateDisambiguationTable(batch); - - CreationResult.TablesCreated += 2; // Now 5 total - - // Add to Data Model - tableCommands.AddToDataModel(batch, "RegionalSalesTable"); - tableCommands.AddToDataModel(batch, "DisambiguationTable"); - CreationResult.TablesLoadedToModel += 2; // Now 5 total - - // Create measures for RegionalSalesTable - dataModelCommands.CreateMeasure( - batch, - "RegionalSalesTable", - "TotalRevenue", - "SUM([Sales])", - "Total Revenue from all regions", - "#,##0"); - - CreationResult.MeasuresCreated++; - - // Create disambiguation measures (names that could be confused with column names) - dataModelCommands.CreateMeasure( - batch, - "DisambiguationTable", - "ACR", // Could be confused with "ACRTypeKey" column - "SUM([Amount])", - "ACR Amount measure", - "#,##0.00"); - - dataModelCommands.CreateMeasure( - batch, - "DisambiguationTable", - "Discount", // Could be confused with "DiscountCode" column - "SUM([Amount]) * 0.1", - "Discount measure", - "#,##0.00"); - - CreationResult.MeasuresCreated += 2; // Now 6 total - - // ======================================== - // PART 3: Create PivotTables - // ======================================== - - // 1. Range-based PivotTable (from SalesData range) - sheetCommands.Create(batch, "PivotData"); - var rangePivot = pivotCommands.CreateFromRange( - batch, - "SalesData", - "A1:F11", // SalesTable range - "PivotData", - "A1", - "SalesByRegion"); - - if (!rangePivot.Success) - throw new InvalidOperationException( - $"CREATION TEST FAILED: Range PivotTable creation failed: {rangePivot.ErrorMessage}"); - - CreationResult.RangePivotTablesCreated = 1; - - // 2. Table-based PivotTable (from RegionalSalesTable) - var tablePivot = pivotCommands.CreateFromTable( - batch, - "RegionalSalesTable", - "RegionalData", - "F1", - "RegionalSummary"); - - if (!tablePivot.Success) - throw new InvalidOperationException( - $"CREATION TEST FAILED: Table PivotTable creation failed: {tablePivot.ErrorMessage}"); - - // Add fields to table pivot - pivotCommands.AddRowField(batch, "RegionalSummary", "Quarter", null); - pivotCommands.AddColumnField(batch, "RegionalSummary", "Region", null); - pivotCommands.AddValueField(batch, "RegionalSummary", "Sales", AggregationFunction.Sum, "Total Sales"); - - CreationResult.TablePivotTablesCreated = 1; - - // 3. Data Model PivotTable (from RegionalSalesTable in Data Model) - sheetCommands.Create(batch, "ModelData"); - var dataModelPivot = pivotCommands.CreateFromDataModel( - batch, - "RegionalSalesTable", - "ModelData", - "A1", - "DataModelPivot"); - - if (!dataModelPivot.Success) - throw new InvalidOperationException( - $"CREATION TEST FAILED: Data Model PivotTable creation failed: {dataModelPivot.ErrorMessage}"); - - // Add fields from Data Model (OLAP requires [TableName].[ColumnName] format) - pivotCommands.AddRowField(batch, "DataModelPivot", "[RegionalSalesTable].[Region]", null); - pivotCommands.AddValueField(batch, "DataModelPivot", "[Measures].[TotalRevenue]", AggregationFunction.Sum, "Revenue"); - - CreationResult.DataModelPivotTablesCreated = 1; - - // 4. Disambiguation PivotTable (for OLAP field matching tests) - sheetCommands.Create(batch, "DisambiguationPivot"); - var disambigPivot = pivotCommands.CreateFromDataModel( - batch, - "DisambiguationTable", - "DisambiguationPivot", - "A1", - "DisambiguationTest"); - - if (!disambigPivot.Success) - throw new InvalidOperationException( - $"CREATION TEST FAILED: Disambiguation PivotTable creation failed: {disambigPivot.ErrorMessage}"); - - CreationResult.DataModelPivotTablesCreated++; - - // Save the workbook - batch.Save(); - - sw.Stop(); - CreationResult.Success = true; - CreationResult.CreationTimeMs = sw.ElapsedMilliseconds; - - Console.WriteLine($"✅ DataModelPivotTable fixture created in {sw.ElapsedMilliseconds}ms:"); - Console.WriteLine($" - {CreationResult.TablesCreated} source tables"); - Console.WriteLine($" - {CreationResult.TablesLoadedToModel} tables in Data Model"); - Console.WriteLine($" - {CreationResult.RelationshipsCreated} relationships"); - Console.WriteLine($" - {CreationResult.MeasuresCreated} DAX measures"); - Console.WriteLine($" - {CreationResult.RangePivotTablesCreated} range-based PivotTables"); - Console.WriteLine($" - {CreationResult.TablePivotTablesCreated} table-based PivotTables"); - Console.WriteLine($" - {CreationResult.DataModelPivotTablesCreated} Data Model PivotTables"); - } - catch (Exception ex) - { - CreationResult.Success = false; - CreationResult.ErrorMessage = ex.Message; - sw.Stop(); - Console.WriteLine($"❌ DataModelPivotTable fixture creation FAILED after {sw.ElapsedMilliseconds}ms: {ex.Message}"); - throw; - } - - return Task.CompletedTask; - } - - /// - /// Creates SalesTable with data for Data Model relationship tests. - /// Columns: SalesID, Date, CustomerID, ProductID, Amount, Quantity - /// - private static void CreateSalesTable(IExcelBatch batch) - { - var sheetCommands = new SheetCommands(); - var rangeCommands = new RangeCommands(); - var tableCommands = new TableCommands(); - - sheetCommands.Create(batch, "SalesData"); - - var allData = new List> - { - new() { "SalesID", "Date", "CustomerID", "ProductID", "Amount", "Quantity" }, - new() { 1, new DateTime(2024, 1, 15), 101, 1001, 150.00, 2 }, - new() { 2, new DateTime(2024, 1, 20), 102, 1002, 250.00, 3 }, - new() { 3, new DateTime(2024, 2, 10), 101, 1003, 175.00, 1 }, - new() { 4, new DateTime(2024, 2, 15), 103, 1001, 300.00, 4 }, - new() { 5, new DateTime(2024, 3, 5), 102, 1002, 125.00, 2 }, - new() { 6, new DateTime(2024, 3, 10), 104, 1003, 450.00, 5 }, - new() { 7, new DateTime(2024, 4, 12), 101, 1001, 200.00, 2 }, - new() { 8, new DateTime(2024, 4, 18), 103, 1002, 350.00, 4 }, - new() { 9, new DateTime(2024, 5, 8), 105, 1003, 275.00, 3 }, - new() { 10, new DateTime(2024, 5, 22), 102, 1001, 180.00, 2 } - }; - - rangeCommands.SetValues(batch, "SalesData", "A1:F11", allData); - tableCommands.Create(batch, "SalesData", "SalesTable", "A1:F11", true); - } - - /// - /// Creates CustomersTable for relationship tests. - /// Columns: CustomerID, Name, Region, Country - /// - private static void CreateCustomersTable(IExcelBatch batch) - { - var sheetCommands = new SheetCommands(); - var rangeCommands = new RangeCommands(); - var tableCommands = new TableCommands(); - - sheetCommands.Create(batch, "Customers"); - - var allData = new List> - { - new() { "CustomerID", "Name", "Region", "Country" }, - new() { 101, "Acme Corp", "North", "USA" }, - new() { 102, "Beta Inc", "South", "USA" }, - new() { 103, "Gamma LLC", "East", "Canada" }, - new() { 104, "Delta Co", "West", "Canada" }, - new() { 105, "Epsilon Ltd", "North", "UK" } - }; - - rangeCommands.SetValues(batch, "Customers", "A1:D6", allData); - tableCommands.Create(batch, "Customers", "CustomersTable", "A1:D6", true); - } - - /// - /// Creates ProductsTable for relationship tests. - /// Columns: ProductID, ProductName, Category, UnitPrice - /// - private static void CreateProductsTable(IExcelBatch batch) - { - var sheetCommands = new SheetCommands(); - var rangeCommands = new RangeCommands(); - var tableCommands = new TableCommands(); - - sheetCommands.Create(batch, "Products"); - - var allData = new List> - { - new() { "ProductID", "ProductName", "Category", "UnitPrice" }, - new() { 1001, "Widget A", "Widgets", 75.00 }, - new() { 1002, "Gadget B", "Gadgets", 125.00 }, - new() { 1003, "Device C", "Devices", 175.00 } - }; - - rangeCommands.SetValues(batch, "Products", "A1:D4", allData); - tableCommands.Create(batch, "Products", "ProductsTable", "A1:D4", true); - } - - /// - /// Creates RegionalSalesTable for PivotTable tests. - /// Columns: Quarter, Region, Sales, Units - /// - private static void CreateRegionalSalesTable(IExcelBatch batch) - { - var sheetCommands = new SheetCommands(); - var rangeCommands = new RangeCommands(); - var tableCommands = new TableCommands(); - - sheetCommands.Create(batch, "RegionalData"); - - var allData = new List> - { - new() { "Quarter", "Region", "Sales", "Units" }, - new() { "Q1", "North", 5000, 100 }, - new() { "Q1", "South", 6000, 120 }, - new() { "Q1", "East", 5500, 110 }, - new() { "Q1", "West", 7000, 140 }, - new() { "Q2", "North", 5500, 110 }, - new() { "Q2", "South", 6500, 130 }, - new() { "Q2", "East", 6000, 120 }, - new() { "Q2", "West", 7500, 150 } - }; - - rangeCommands.SetValues(batch, "RegionalData", "A1:D9", allData); - tableCommands.Create(batch, "RegionalData", "RegionalSalesTable", "A1:D9", true); - } - - /// - /// Creates DisambiguationTable for OLAP field matching tests. - /// Has columns that could be confused with measure names: - /// - "ACRTypeKey" column vs "ACR" measure - /// - "DiscountCode" column vs "Discount" measure - /// - private static void CreateDisambiguationTable(IExcelBatch batch) - { - var sheetCommands = new SheetCommands(); - var rangeCommands = new RangeCommands(); - var tableCommands = new TableCommands(); - - sheetCommands.Create(batch, "DisambiguationData"); - - var allData = new List> - { - new() { "ID", "ACRTypeKey", "DiscountCode", "Amount", "Category" }, - new() { 1, "ACR001", "DISC10", 1000.00, "TypeA" }, - new() { 2, "ACR002", "DISC20", 2500.00, "TypeB" }, - new() { 3, "ACR001", "DISC10", 1500.00, "TypeA" }, - new() { 4, "ACR003", "DISC30", 800.00, "TypeC" }, - new() { 5, "ACR002", "DISC20", 3000.00, "TypeB" } - }; - - rangeCommands.SetValues(batch, "DisambiguationData", "A1:E6", allData); - tableCommands.Create(batch, "DisambiguationData", "DisambiguationTable", "A1:E6", true); - } - - public Task DisposeAsync() - { - try - { - if (Directory.Exists(_tempDir)) - Directory.Delete(_tempDir, true); - } - catch - { - // Ignore cleanup errors - } - - return Task.CompletedTask; - } -} - -/// -/// Results of comprehensive Data Model + PivotTable workbook creation. -/// -public class DataModelPivotTableCreationResult -{ - public bool Success { get; set; } - public bool FileCreated { get; set; } - public int TablesCreated { get; set; } - public int TablesLoadedToModel { get; set; } - public int RelationshipsCreated { get; set; } - public int MeasuresCreated { get; set; } - public int RangePivotTablesCreated { get; set; } - public int TablePivotTablesCreated { get; set; } - public int DataModelPivotTablesCreated { get; set; } - public long CreationTimeMs { get; set; } - public string? ErrorMessage { get; set; } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Helpers/DataModelTestCollection.cs b/tests/ExcelMcp.Core.Tests/Helpers/DataModelTestCollection.cs deleted file mode 100644 index 4ace36d6..00000000 --- a/tests/ExcelMcp.Core.Tests/Helpers/DataModelTestCollection.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Helpers; - -/// -/// Collection definition for tests that share the DataModelPivotTableFixture. -/// This creates ONE fixture instance shared across ALL test classes in this collection. -/// -/// Usage: Add [Collection("DataModel")] attribute to test classes that need the fixture. -/// The fixture is injected via constructor parameter. -/// -/// Benefits: -/// - Fixture created ONCE for all test classes in the collection (~1.5 min setup) -/// - Instead of once per test class (6 classes × ~1.5 min = ~9 min setup) -/// - Saves ~7.5 minutes of test execution time -/// -[CollectionDefinition("DataModel")] -public class DataModelTestsDefinition : ICollectionFixture -{ - // This class has no code - it's just a marker for xUnit - // to associate the collection name with the fixture type -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Helpers/DataModelTestsFixture.cs b/tests/ExcelMcp.Core.Tests/Helpers/DataModelTestsFixture.cs deleted file mode 100644 index 0b6334e2..00000000 --- a/tests/ExcelMcp.Core.Tests/Helpers/DataModelTestsFixture.cs +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) Sbroenne. All rights reserved. -// Licensed under the MIT License. - -using System.Runtime.CompilerServices; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Helpers; - -/// -/// Fixture for Data Model tests that need isolated test files. -/// Provides temp directory and file creation helpers. -/// - Created ONCE before any tests run -/// - Each test creates unique files via CreateTestFile() -/// - Temp directory cleaned up after all tests complete -/// -public class DataModelTestsFixture : IAsyncLifetime -{ - private readonly string _tempDir; - - /// - /// Temp directory for all test files (auto-cleaned on disposal) - /// - public string TempDir => _tempDir; - - public DataModelTestsFixture() - { - _tempDir = Path.Join(Path.GetTempPath(), $"DataModelTests_{Guid.NewGuid():N}"); - Directory.CreateDirectory(_tempDir); - } - - /// - /// Creates a unique test file for tests that need their own file. - /// File name includes test name + GUID for uniqueness. - /// - /// Test name (auto-populated via CallerMemberName) - /// File extension (default: .xlsx) - /// Path to the new test file - public string CreateTestFile([CallerMemberName] string testName = "", string extension = ".xlsx") - { - var fileName = $"{testName}_{Guid.NewGuid():N}{extension}"; - var filePath = Path.Join(_tempDir, fileName); - using var manager = new SessionManager(); - var sessionId = manager.CreateSessionForNewFile(filePath, show: false); - manager.CloseSession(sessionId, save: true); - return filePath; - } - - /// - /// Called ONCE before any tests in the class run. - /// - public Task InitializeAsync() - { - return Task.CompletedTask; - } - - /// - /// Called ONCE after all tests in the class complete. - /// - public Task DisposeAsync() - { - try - { - if (Directory.Exists(_tempDir)) - { - Directory.Delete(_tempDir, recursive: true); - } - } - catch - { - // Cleanup is best-effort - } - return Task.CompletedTask; - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Helpers/FileTestsFixture.cs b/tests/ExcelMcp.Core.Tests/Helpers/FileTestsFixture.cs deleted file mode 100644 index 74261c22..00000000 --- a/tests/ExcelMcp.Core.Tests/Helpers/FileTestsFixture.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System.Runtime.CompilerServices; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Helpers; - -/// -/// Fixture for File tests providing efficient test file creation. -/// Each test gets a unique .xlsx file via CreateTestFile() method. -/// -public class FileTestsFixture : IAsyncLifetime -{ - private readonly string _tempDir; - - /// - /// Temp directory for all test files (auto-cleaned on disposal) - /// - public string TempDir => _tempDir; - - public FileTestsFixture() - { - _tempDir = Path.Join(Path.GetTempPath(), $"FileTests_{Guid.NewGuid():N}"); - Directory.CreateDirectory(_tempDir); - } - - /// - public Task InitializeAsync() - { - return Task.CompletedTask; - } - - /// - public Task DisposeAsync() - { - try - { - if (Directory.Exists(_tempDir)) - { - Directory.Delete(_tempDir, recursive: true); - } - } - catch - { - // Cleanup is best-effort - } - return Task.CompletedTask; - } - - /// - /// Creates a unique empty .xlsx test file for the calling test. - /// Uses [CallerMemberName] to auto-populate the test name. - /// - /// Auto-populated from caller method name - /// Path to the unique .xlsx test file - public string CreateTestFile([CallerMemberName] string testName = "") - { - var guid = Guid.NewGuid().ToString("N")[..8]; - var testFile = Path.Join(_tempDir, $"File_{testName}_{guid}.xlsx"); - using var manager = new SessionManager(); - var sessionId = manager.CreateSessionForNewFile(testFile, show: false); - manager.CloseSession(sessionId, save: true); - return testFile; - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Helpers/NamedRangeTestsFixture.cs b/tests/ExcelMcp.Core.Tests/Helpers/NamedRangeTestsFixture.cs deleted file mode 100644 index fd97e210..00000000 --- a/tests/ExcelMcp.Core.Tests/Helpers/NamedRangeTestsFixture.cs +++ /dev/null @@ -1,127 +0,0 @@ -// -// Copyright (c) Stephan Brenner. All rights reserved. -// - -using System.Diagnostics; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Helpers; - -/// -/// Fixture that creates ONE NamedRange test file per test CLASS. -/// Each test uses unique named range names for isolation. -/// - Created ONCE before any tests run (~3-5s) -/// - Shared by all tests in the class -/// - Each test creates its own named ranges with unique names -/// - Reduces file creation overhead by ~95% -/// -public class NamedRangeTestsFixture : IAsyncLifetime -{ - private readonly string _tempDir; - private int _cellCounter; - - /// - /// Path to the shared test file - /// - public string TestFilePath { get; private set; } = null!; - - /// - /// Results of fixture creation (exposed for validation) - /// - public FixtureCreationResult CreationResult { get; private set; } = null!; - - /// - /// Initializes a new instance of the fixture - /// - public NamedRangeTestsFixture() - { - _tempDir = Path.Join(Path.GetTempPath(), $"NamedRangeTests_{Guid.NewGuid():N}"); - Directory.CreateDirectory(_tempDir); - } - - /// - /// Gets a unique named range name for test isolation. - /// Named range names are limited to 255 chars (Excel limit). - /// - public static string GetUniqueNamedRangeName([System.Runtime.CompilerServices.CallerMemberName] string testName = "") - { - // Create a unique name that fits within Excel's 255 character limit - var uniqueId = Guid.NewGuid().ToString("N")[..8]; - var prefix = $"NR_{uniqueId}_"; - var maxNameLength = 255 - prefix.Length; - var shortName = testName.Length > maxNameLength ? testName[..maxNameLength] : testName; - return $"{prefix}{shortName}"; - } - - /// - /// Gets a unique cell reference for the named range (to avoid conflicts). - /// Uses incrementing row numbers to avoid conflicts. - /// - public string GetUniqueCellReference() - { - // Use incrementing counter for unique cell references - var counter = Interlocked.Increment(ref _cellCounter); - var col = ((counter - 1) % 26) + 1; // A-Z - var row = ((counter - 1) / 26) + 1; // 1, 2, 3... - var colLetter = (char)('A' + col - 1); - return $"Sheet1!{colLetter}{row}"; - } - - /// - /// Called ONCE before any tests in the class run. - /// Creates a workbook that tests will add named ranges to. - /// - public Task InitializeAsync() - { - var sw = Stopwatch.StartNew(); - - TestFilePath = Path.Join(_tempDir, "NamedRangeTest.xlsx"); - CreationResult = new FixtureCreationResult(); - - try - { - using var manager = new SessionManager(); - var sessionId = manager.CreateSessionForNewFile(TestFilePath, show: false); - manager.CloseSession(sessionId, save: true); - CreationResult.FileCreated = true; - - sw.Stop(); - CreationResult.Success = true; - CreationResult.CreationTimeSeconds = sw.Elapsed.TotalSeconds; - } - catch (Exception ex) - { - CreationResult.Success = false; - CreationResult.ErrorMessage = ex.Message; - sw.Stop(); - throw; - } - - return Task.CompletedTask; - } - - /// - /// Called ONCE after all tests in the class complete. - /// - public Task DisposeAsync() - { - try - { - if (Directory.Exists(_tempDir)) - { - Directory.Delete(_tempDir, recursive: true); - } - } - catch - { - // Cleanup is best-effort - } - - return Task.CompletedTask; - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Helpers/PivotTableTestsFixture.cs b/tests/ExcelMcp.Core.Tests/Helpers/PivotTableTestsFixture.cs deleted file mode 100644 index 103418f1..00000000 --- a/tests/ExcelMcp.Core.Tests/Helpers/PivotTableTestsFixture.cs +++ /dev/null @@ -1,183 +0,0 @@ -using System.Diagnostics; -using System.Runtime.CompilerServices; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Helpers; - -/// -/// Fixture that creates ONE PivotTable test file per test CLASS. -/// The fixture initialization IS the test for PivotTable data preparation. -/// - Created ONCE before any tests run (~5-10s) -/// - Shared READ-ONLY by all tests in the class -/// - Each test gets its own batch (isolation at batch level) -/// - No file sharing between test classes -/// - Creation results exposed for validation tests -/// - CreateTestFile() available for tests that need unique files -/// -public class PivotTableTestsFixture : IAsyncLifetime -{ - private readonly string _tempDir; - - /// - /// Temp directory for all test files (auto-cleaned on disposal) - /// - public string TempDir => _tempDir; - - /// - /// Path to the test PivotTable file - /// - public string TestFilePath { get; private set; } = null!; - - /// - /// Results of data creation (exposed for validation) - /// - public PivotTableCreationResult CreationResult { get; private set; } = null!; - - public PivotTableTestsFixture() - { - _tempDir = Path.Join(Path.GetTempPath(), $"PivotTableTests_{Guid.NewGuid():N}"); - Directory.CreateDirectory(_tempDir); - } - - /// - /// Called ONCE before any tests in the class run. - /// This IS the test for data preparation - if it fails, all tests fail (correct behavior). - /// Tests: file creation, sales data creation, persistence. - /// - public Task InitializeAsync() - { - var sw = Stopwatch.StartNew(); - - TestFilePath = Path.Join(_tempDir, "PivotTableTest.xlsx"); - CreationResult = new PivotTableCreationResult(); - - try - { - using (var manager = new SessionManager()) - { - var sessionId = manager.CreateSessionForNewFile(TestFilePath, show: false); - manager.CloseSession(sessionId, save: true); - } - - CreationResult.FileCreated = true; - - using var batch = ExcelSession.BeginBatch(TestFilePath); - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - sheet.Name = "SalesData"; - - // Headers - sheet.Range["A1"].Value2 = "Region"; - sheet.Range["B1"].Value2 = "Product"; - sheet.Range["C1"].Value2 = "Sales"; - sheet.Range["D1"].Value2 = "Date"; - - // Sample data (5 rows) - sheet.Range["A2"].Value2 = "North"; - sheet.Range["B2"].Value2 = "Widget"; - sheet.Range["C2"].Value2 = 100; - sheet.Range["D2"].Value2 = new DateTime(2025, 1, 15); - - sheet.Range["A3"].Value2 = "North"; - sheet.Range["B3"].Value2 = "Widget"; - sheet.Range["C3"].Value2 = 150; - sheet.Range["D3"].Value2 = new DateTime(2025, 1, 20); - - sheet.Range["A4"].Value2 = "South"; - sheet.Range["B4"].Value2 = "Gadget"; - sheet.Range["C4"].Value2 = 200; - sheet.Range["D4"].Value2 = new DateTime(2025, 2, 10); - - sheet.Range["A5"].Value2 = "North"; - sheet.Range["B5"].Value2 = "Gadget"; - sheet.Range["C5"].Value2 = 75; - sheet.Range["D5"].Value2 = new DateTime(2025, 2, 15); - - sheet.Range["A6"].Value2 = "South"; - sheet.Range["B6"].Value2 = "Widget"; - sheet.Range["C6"].Value2 = 125; - sheet.Range["D6"].Value2 = new DateTime(2025, 3, 5); - - return 0; - }); - - CreationResult.DataRowsCreated = 5; - - batch.Save(); - - sw.Stop(); - CreationResult.Success = true; - CreationResult.CreationTimeSeconds = sw.Elapsed.TotalSeconds; - } - catch (Exception ex) - { - CreationResult.Success = false; - CreationResult.ErrorMessage = ex.Message; - - sw.Stop(); - - throw; // Fail all tests in class (correct behavior - no point testing if creation failed) - } - - return Task.CompletedTask; - } - - /// - /// Creates a unique test file for tests that need their own file. - /// File name includes test name + GUID for uniqueness. - /// - /// Test name (auto-populated via CallerMemberName) - /// File extension (default: .xlsx) - /// Path to the new test file - public string CreateTestFile([CallerMemberName] string testName = "", string extension = ".xlsx") - { - var fileName = $"{testName}_{Guid.NewGuid():N}{extension}"; - var filePath = Path.Join(_tempDir, fileName); - using var manager = new SessionManager(); - var sessionId = manager.CreateSessionForNewFile(filePath, show: false); - manager.CloseSession(sessionId, save: true); - return filePath; - } - - /// - /// Called ONCE after all tests in the class complete. - /// - public Task DisposeAsync() - { - try - { - if (Directory.Exists(_tempDir)) - { - Directory.Delete(_tempDir, recursive: true); - } - } - catch - { - // Cleanup is best-effort - } - return Task.CompletedTask; - } -} - -/// -/// Results of PivotTable data creation (exposed by fixture for validation tests) -/// -public class PivotTableCreationResult -{ - /// - public bool Success { get; set; } - /// - public bool FileCreated { get; set; } - /// - public int DataRowsCreated { get; set; } - /// - public double CreationTimeSeconds { get; set; } - /// - public string? ErrorMessage { get; set; } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Helpers/PowerQueryTestsFixture.cs b/tests/ExcelMcp.Core.Tests/Helpers/PowerQueryTestsFixture.cs deleted file mode 100644 index 15fed299..00000000 --- a/tests/ExcelMcp.Core.Tests/Helpers/PowerQueryTestsFixture.cs +++ /dev/null @@ -1,231 +0,0 @@ -using System.Diagnostics; -using System.Runtime.CompilerServices; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands; -using Sbroenne.ExcelMcp.Core.Models; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Helpers; - -/// -/// Fixture that creates ONE Power Query test file per test CLASS. -/// The fixture initialization IS the test for Power Query creation. -/// - Created ONCE before any tests run (~10-15s) -/// - Shared READ-ONLY by all tests in the class -/// - Each test gets its own batch (isolation at batch level) -/// - No file sharing between test classes -/// - Creation results exposed for validation tests -/// - CreateTestFile() available for tests that need unique files -/// -public class PowerQueryTestsFixture : IAsyncLifetime -{ - private readonly string _tempDir; - - /// - /// Temp directory for all test files (auto-cleaned on disposal) - /// - public string TempDir => _tempDir; - - /// - /// Path to the test Power Query file - /// - public string TestFilePath { get; private set; } = null!; - - /// - /// Results of Power Query creation (exposed for validation) - /// - public PowerQueryCreationResult CreationResult { get; private set; } = null!; - - public PowerQueryTestsFixture() - { - _tempDir = Path.Join(Path.GetTempPath(), $"PowerQueryTests_{Guid.NewGuid():N}"); - Directory.CreateDirectory(_tempDir); - } - - /// - /// Called ONCE before any tests in the class run. - /// This IS the test for Power Query creation - if it fails, all tests fail (correct behavior). - /// Tests: file creation, M code file creation, Import, persistence. - /// - public Task InitializeAsync() - { - var sw = Stopwatch.StartNew(); - - TestFilePath = Path.Join(_tempDir, "PowerQuery.xlsx"); - CreationResult = new PowerQueryCreationResult(); - - try - { - using (var manager = new SessionManager()) - { - var sessionId = manager.CreateSessionForNewFile(TestFilePath, show: false); - manager.CloseSession(sessionId, save: true); - } - - CreationResult.FileCreated = true; - - using var batch = ExcelSession.BeginBatch(TestFilePath); - - var mCodeFiles = new string[3]; - mCodeFiles[0] = CreateMCodeFile("BasicQuery", CreateBasicMCode()); - mCodeFiles[1] = CreateMCodeFile("DataQuery", CreateDataQueryMCode()); - mCodeFiles[2] = CreateMCodeFile("RefreshableQuery", CreateRefreshableQueryMCode()); - CreationResult.MCodeFilesCreated = 3; - - var dataModelCommands = new DataModelCommands(); - var powerQueryCommands = new PowerQueryCommands(dataModelCommands); - - // Create throws on error, so reaching here means success - powerQueryCommands.Create(batch, "BasicQuery", File.ReadAllText(mCodeFiles[0]), PowerQueryLoadMode.ConnectionOnly); - powerQueryCommands.Create(batch, "DataQuery", File.ReadAllText(mCodeFiles[1]), PowerQueryLoadMode.ConnectionOnly); - powerQueryCommands.Create(batch, "RefreshableQuery", File.ReadAllText(mCodeFiles[2]), PowerQueryLoadMode.ConnectionOnly); - - CreationResult.QueriesImported = 3; - - // ═══════════════════════════════════════════════════════ - // TEST 4: Persistence (Save) - // ═══════════════════════════════════════════════════════ - batch.Save(); - - sw.Stop(); - CreationResult.Success = true; - CreationResult.CreationTimeSeconds = sw.Elapsed.TotalSeconds; - - } - catch (Exception ex) - { - CreationResult.Success = false; - CreationResult.ErrorMessage = ex.Message; - - sw.Stop(); - - throw; // Fail all tests in class (correct behavior - no point testing if creation failed) - } - - return Task.CompletedTask; - } - - /// - /// Called ONCE after all tests in the class complete. - /// - public Task DisposeAsync() - { - try - { - if (Directory.Exists(_tempDir)) - { - Directory.Delete(_tempDir, recursive: true); - } - } - catch - { - // Cleanup is best-effort - } - return Task.CompletedTask; - } - - /// - /// Creates a unique empty test file for the calling test. - /// Uses [CallerMemberName] to auto-populate the test name. - /// - /// Auto-populated from caller method name - /// Path to the unique test file - public string CreateTestFile([CallerMemberName] string testName = "") - { - var guid = Guid.NewGuid().ToString("N")[..8]; - var testFile = Path.Join(_tempDir, $"PQ_{testName}_{guid}.xlsx"); - using var manager = new SessionManager(); - var sessionId = manager.CreateSessionForNewFile(testFile, show: false); - manager.CloseSession(sessionId, save: true); - return testFile; - } - - /// - /// Creates an M code file in the temp directory - /// - private string CreateMCodeFile(string name, string mCode) - { - var filePath = Path.Join(_tempDir, $"{name}.pq"); - File.WriteAllText(filePath, mCode); - return filePath; - } - - /// - /// Creates basic M code for simple queries - /// - private static string CreateBasicMCode() - { - return @"let - Source = #table( - {""Column1"", ""Column2"", ""Column3""}, - { - {""Value1"", ""Value2"", ""Value3""}, - {""A"", ""B"", ""C""}, - {""X"", ""Y"", ""Z""} - } - ) -in - Source"; - } - - /// - /// Creates M code with more data for testing - /// - private static string CreateDataQueryMCode() - { - return @"let - Source = #table( - {""ID"", ""Name"", ""Value""}, - { - {1, ""Item1"", 100}, - {2, ""Item2"", 200}, - {3, ""Item3"", 300}, - {4, ""Item4"", 400}, - {5, ""Item5"", 500} - } - ) -in - Source"; - } - - /// - /// Creates M code for refreshable query testing - /// - private static string CreateRefreshableQueryMCode() - { - return @"let - Source = #table( - {""Date"", ""Amount""}, - { - {#date(2024, 1, 1), 1000}, - {#date(2024, 2, 1), 2000}, - {#date(2024, 3, 1), 3000} - } - ) -in - Source"; - } -} - -/// -/// Results of Power Query creation (exposed by fixture for validation tests) -/// -public class PowerQueryCreationResult -{ - /// - public bool Success { get; set; } - /// - public bool FileCreated { get; set; } - /// - public int MCodeFilesCreated { get; set; } - /// - public int QueriesImported { get; set; } - /// - public double CreationTimeSeconds { get; set; } - /// - public string? ErrorMessage { get; set; } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Helpers/RangeTestsFixture.cs b/tests/ExcelMcp.Core.Tests/Helpers/RangeTestsFixture.cs deleted file mode 100644 index 33fb6715..00000000 --- a/tests/ExcelMcp.Core.Tests/Helpers/RangeTestsFixture.cs +++ /dev/null @@ -1,171 +0,0 @@ -using System.Diagnostics; -using System.Runtime.CompilerServices; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Helpers; - -/// -/// Fixture that creates ONE Range test file per test CLASS. -/// Each test uses different sheets within the same file for isolation. -/// - Created ONCE before any tests run (~3-5s) -/// - Shared by all tests in the class -/// - Each test gets its own batch AND its own sheet (isolation) -/// - Reduces file creation overhead by ~95% -/// -public class RangeTestsFixture : IAsyncLifetime -{ - private readonly string _tempDir; - private int _sheetCounter; - - /// - /// Temp directory for all test files (auto-cleaned on disposal) - /// - public string TempDir => _tempDir; - - /// - /// Path to the test Range file - /// - public string TestFilePath { get; private set; } = null!; - - /// - /// Results of fixture creation (exposed for validation) - /// - public FixtureCreationResult CreationResult { get; private set; } = null!; - - /// - /// Initializes a new instance of the fixture - /// - public RangeTestsFixture() - { - _tempDir = Path.Join(Path.GetTempPath(), $"RangeTests_{Guid.NewGuid():N}"); - Directory.CreateDirectory(_tempDir); - } - - /// - /// Gets a unique sheet name for test isolation. - /// Each test should call this to get its own sheet. - /// Sheet names are limited to 31 chars (Excel limit). - /// - public string GetUniqueSheetName([CallerMemberName] string testName = "") - { - var counter = Interlocked.Increment(ref _sheetCounter); - // Limit sheet name to 31 chars (Excel limit) - format: "T001_TestMethodName" - var prefix = $"T{counter:D3}_"; - var maxNameLength = 31 - prefix.Length; - var shortName = testName.Length > maxNameLength ? testName[..maxNameLength] : testName; - return $"{prefix}{shortName}"; - } - - /// - /// Creates a unique sheet for a test and returns its name. - /// Call this in the Arrange phase of each test. - /// - public string CreateTestSheet(IExcelBatch batch, [CallerMemberName] string testName = "") - { - var sheetName = GetUniqueSheetName(testName); - - batch.Execute((ctx, ct) => - { - dynamic sheets = ctx.Book.Worksheets; - dynamic newSheet = sheets.Add(After: sheets.Item(sheets.Count)); - newSheet.Name = sheetName; - return 0; - }); - - return sheetName; - } - - /// - /// Creates a unique test file for tests that need their own file. - /// File name includes test name + GUID for uniqueness. - /// - /// Test name (auto-populated via CallerMemberName) - /// File extension (default: .xlsx) - /// Path to the new test file - public string CreateTestFile([CallerMemberName] string testName = "", string extension = ".xlsx") - { - var fileName = $"{testName}_{Guid.NewGuid():N}{extension}"; - var filePath = Path.Join(_tempDir, fileName); - using var manager = new SessionManager(); - var sessionId = manager.CreateSessionForNewFile(filePath, show: false); - manager.CloseSession(sessionId, save: true); - return filePath; - } - - /// - /// Called ONCE before any tests in the class run. - /// Creates a workbook that tests will add sheets to. - /// - public Task InitializeAsync() - { - var sw = Stopwatch.StartNew(); - - TestFilePath = Path.Join(_tempDir, "RangeTest.xlsx"); - CreationResult = new FixtureCreationResult(); - - try - { - using (var manager = new SessionManager()) - { - var sessionId = manager.CreateSessionForNewFile(TestFilePath, show: false); - manager.CloseSession(sessionId, save: true); - } - CreationResult.FileCreated = true; - - sw.Stop(); - CreationResult.Success = true; - CreationResult.CreationTimeSeconds = sw.Elapsed.TotalSeconds; - } - catch (Exception ex) - { - CreationResult.Success = false; - CreationResult.ErrorMessage = ex.Message; - sw.Stop(); - throw; - } - - return Task.CompletedTask; - } - - /// - /// Called ONCE after all tests in the class complete. - /// - public Task DisposeAsync() - { - try - { - if (Directory.Exists(_tempDir)) - { - Directory.Delete(_tempDir, recursive: true); - } - } - catch - { - // Cleanup is best-effort - } - return Task.CompletedTask; - } -} - -/// -/// Generic fixture creation result -/// -public class FixtureCreationResult -{ - /// Whether fixture creation succeeded - public bool Success { get; set; } - - /// Whether the Excel file was created - public bool FileCreated { get; set; } - - /// Time taken to create the fixture - public double CreationTimeSeconds { get; set; } - - /// Error message if creation failed - public string? ErrorMessage { get; set; } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Helpers/ScreenshotTestsFixture.cs b/tests/ExcelMcp.Core.Tests/Helpers/ScreenshotTestsFixture.cs deleted file mode 100644 index f84d7121..00000000 --- a/tests/ExcelMcp.Core.Tests/Helpers/ScreenshotTestsFixture.cs +++ /dev/null @@ -1,64 +0,0 @@ -// -// Copyright (c) Stephan Brenner. All rights reserved. -// - -using System.Runtime.CompilerServices; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Helpers; - -/// -/// Shared fixture for ScreenshotCommandsTests. -/// Each test creates its own file with data via CreateTestFile(). -/// -public class ScreenshotTestsFixture : IAsyncLifetime -{ - private readonly string _tempDir; - - /// - /// Initializes a new instance of the class. - /// - public ScreenshotTestsFixture() - { - _tempDir = Path.Combine(Path.GetTempPath(), $"ScreenshotTests_{Guid.NewGuid():N}"); - Directory.CreateDirectory(_tempDir); - } - - /// - /// Creates a unique test file for a test. - /// - public string CreateTestFile([CallerMemberName] string testName = "") - { - var fileName = $"{testName}_{Guid.NewGuid():N}.xlsx"; - var filePath = Path.Combine(_tempDir, fileName); - - using var manager = new SessionManager(); - var sessionId = manager.CreateSessionForNewFile(filePath, show: false); - manager.CloseSession(sessionId, save: true); - - return filePath; - } - - /// - public Task InitializeAsync() => Task.CompletedTask; - - /// - public Task DisposeAsync() - { - try - { - if (Directory.Exists(_tempDir)) - { - Directory.Delete(_tempDir, recursive: true); - } - } - catch - { - // Ignore cleanup errors - } - - return Task.CompletedTask; - } -} - diff --git a/tests/ExcelMcp.Core.Tests/Helpers/SheetTestsFixture.cs b/tests/ExcelMcp.Core.Tests/Helpers/SheetTestsFixture.cs deleted file mode 100644 index 0e72241f..00000000 --- a/tests/ExcelMcp.Core.Tests/Helpers/SheetTestsFixture.cs +++ /dev/null @@ -1,127 +0,0 @@ -// -// Copyright (c) Stephan Brenner. All rights reserved. -// - -using System.Runtime.CompilerServices; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Helpers; - -/// -/// Shared fixture for SheetCommandsTests that provides a single Excel file for single-workbook tests. -/// Cross-file tests (CopyToFile, MoveToFile) create their own file pairs. -/// Uses IAsyncLifetime to create file once for all tests, reducing test execution time. -/// -public class SheetTestsFixture : IAsyncLifetime -{ - private readonly string _tempDir; - private int _sheetCounter; - - /// - /// Initializes a new instance of the class. - /// - public SheetTestsFixture() - { - _tempDir = Path.Combine(Path.GetTempPath(), $"SheetTests_{Guid.NewGuid():N}"); - Directory.CreateDirectory(_tempDir); - } - - /// - /// Gets the path to the shared test file for single-workbook tests. - /// - public string TestFilePath { get; private set; } = string.Empty; - - /// - /// Gets the temp directory for cross-workbook tests that need their own files. - /// - public string TempDir => _tempDir; - - /// - /// Creates the shared Excel file. - /// - public Task InitializeAsync() - { - TestFilePath = Path.Combine(_tempDir, "SheetTests_Shared.xlsx"); - using var manager = new SessionManager(); - var sessionId = manager.CreateSessionForNewFile(TestFilePath, show: false); - manager.CloseSession(sessionId, save: true); - return Task.CompletedTask; - } - - /// - /// Cleans up the temp directory and all test files. - /// - public Task DisposeAsync() - { - try - { - if (Directory.Exists(_tempDir)) - { - Directory.Delete(_tempDir, recursive: true); - } - } - catch - { - // Ignore cleanup errors - } - - return Task.CompletedTask; - } - - /// - /// Creates a unique sheet name for test isolation within the shared workbook. - /// Call this at the start of each single-workbook test to get a unique sheet to work with. - /// - /// The Excel batch to create the sheet in. - /// Test method name (auto-captured via CallerMemberName). - /// The name of the created test sheet. - public string CreateTestSheet(IExcelBatch batch, [CallerMemberName] string testName = "") - { - var sheetNum = Interlocked.Increment(ref _sheetCounter); - var sheetName = $"T{sheetNum}_{testName}"; - - // Truncate if too long (Excel max is 31 chars) - if (sheetName.Length > 31) - { - sheetName = $"T{sheetNum}_{testName[..(31 - $"T{sheetNum}_".Length)]}"; - } - - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets.Add(); - sheet.Name = sheetName; - }); - - return sheetName; - } - - /// - /// Creates a unique test file for cross-workbook tests that need their own isolated files. - /// - /// Test method name. - /// Optional suffix to distinguish source/target files. - /// Path to the created test file. - public string CreateCrossWorkbookTestFile(string testName, string suffix = "") - { - var fileName = string.IsNullOrEmpty(suffix) - ? $"{testName}_{Guid.NewGuid():N}.xlsx" - : $"{testName}_{suffix}_{Guid.NewGuid():N}.xlsx"; - - // Truncate filename if too long - if (fileName.Length > 200) - { - fileName = $"{testName[..50]}_{suffix}_{Guid.NewGuid():N}.xlsx"; - } - - var filePath = Path.Combine(_tempDir, fileName); - using var manager = new SessionManager(); - var sessionId = manager.CreateSessionForNewFile(filePath, show: false); - manager.CloseSession(sessionId, save: true); - return filePath; - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Helpers/TableTestsFixture.cs b/tests/ExcelMcp.Core.Tests/Helpers/TableTestsFixture.cs deleted file mode 100644 index 2a0f1d76..00000000 --- a/tests/ExcelMcp.Core.Tests/Helpers/TableTestsFixture.cs +++ /dev/null @@ -1,237 +0,0 @@ -using System.Diagnostics; -using System.Runtime.CompilerServices; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands.Table; -using Sbroenne.ExcelMcp.Core.Models; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Helpers; - -/// -/// Fixture that creates ONE Table test file per test CLASS. -/// The fixture initialization IS the test for Table creation. -/// - Created ONCE before any tests run (~5-10s) -/// - Shared READ-ONLY by all tests in the class -/// - Each test gets its own batch (isolation at batch level) -/// - No file sharing between test classes -/// - Creation results exposed for validation tests -/// - Provides CreateModificationTestFile() for tests that need isolated files -/// -public class TableTestsFixture : IAsyncLifetime -{ - private readonly string _tempDir; - private readonly TableCommands _tableCommands = new TableCommands(); - - /// - /// Path to the test Table file (shared READ-ONLY) - /// - public string TestFilePath { get; private set; } = null!; - - /// - /// Temp directory for modification tests that need unique files - /// - public string TempDir => _tempDir; - - /// - /// Results of Table creation (exposed for validation) - /// - public TableCreationResult CreationResult { get; private set; } = null!; - /// - - public TableTestsFixture() - { - _tempDir = Path.Join(Path.GetTempPath(), $"TableTests_{Guid.NewGuid():N}"); - Directory.CreateDirectory(_tempDir); - } - - /// - /// Called ONCE before any tests in the class run. - /// This IS the test for Table creation - if it fails, all tests fail (correct behavior). - /// Tests: file creation, data creation, TableCommands.Create(), persistence. - /// - public Task InitializeAsync() - { - var sw = Stopwatch.StartNew(); - - TestFilePath = Path.Join(_tempDir, "TableTest.xlsx"); - CreationResult = new TableCreationResult(); - - try - { - // TEST 1: File Creation - using (var manager = new SessionManager()) - { - var sessionId = manager.CreateSessionForNewFile(TestFilePath, show: false); - manager.CloseSession(sessionId, save: true); - } - - CreationResult.FileCreated = true; - - using var batch = ExcelSession.BeginBatch(TestFilePath); - - // TEST 2: Data Creation and Table Creation - - // Create sample sales data - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - sheet.Name = "Sales"; - - // Add headers - sheet.Range["A1"].Value2 = "Region"; - sheet.Range["B1"].Value2 = "Product"; - sheet.Range["C1"].Value2 = "Amount"; - sheet.Range["D1"].Value2 = "Date"; - - // Add sample data - sheet.Range["A2"].Value2 = "North"; - sheet.Range["B2"].Value2 = "Widget"; - sheet.Range["C2"].Value2 = 100; - sheet.Range["D2"].Value2 = new DateTime(2025, 1, 15); - - sheet.Range["A3"].Value2 = "South"; - sheet.Range["B3"].Value2 = "Gadget"; - sheet.Range["C3"].Value2 = 250; - sheet.Range["D3"].Value2 = new DateTime(2025, 2, 20); - - sheet.Range["A4"].Value2 = "East"; - sheet.Range["B4"].Value2 = "Widget"; - sheet.Range["C4"].Value2 = 150; - sheet.Range["D4"].Value2 = new DateTime(2025, 3, 10); - - sheet.Range["A5"].Value2 = "West"; - sheet.Range["B5"].Value2 = "Gadget"; - sheet.Range["C5"].Value2 = 300; - sheet.Range["D5"].Value2 = new DateTime(2025, 1, 25); - - return 0; - }); - - // Create Table using TableCommands - var tableCommands = new TableCommands(); - // Create throws on error, so reaching here means success - tableCommands.Create( - batch, "Sales", "SalesTable", "A1:D5", hasHeaders: true, tableStyle: TableStylePresets.Medium2); - - CreationResult.TablesCreated = 1; - - // TEST 3: Persistence (Save) - batch.Save(); - - sw.Stop(); - CreationResult.Success = true; - CreationResult.CreationTimeSeconds = sw.Elapsed.TotalSeconds; - } - catch (Exception ex) - { - CreationResult.Success = false; - CreationResult.ErrorMessage = ex.Message; - sw.Stop(); - throw; // Fail all tests in class (correct behavior - no point testing if creation failed) - } - - return Task.CompletedTask; - } - - /// - /// Called ONCE after all tests in the class complete. - /// - public Task DisposeAsync() - { - try - { - if (Directory.Exists(_tempDir)) - { - Directory.Delete(_tempDir, recursive: true); - } - } - catch - { - // Cleanup is best-effort - } - return Task.CompletedTask; - } - - /// - /// Creates a unique test file with SalesTable for modification tests. - /// Use this for tests that modify the table (delete, rename, resize, etc.) - /// - /// Auto-populated from caller method name - /// Path to the unique test file - public string CreateModificationTestFile([CallerMemberName] string testName = "") - { - var guid = Guid.NewGuid().ToString("N")[..8]; - var testFile = Path.Join(_tempDir, $"Table_{testName}_{guid}.xlsx"); - - using (var manager = new SessionManager()) - { - var sessionId = manager.CreateSessionForNewFile(testFile, show: false); - manager.CloseSession(sessionId, save: true); - } - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create worksheet with sample data - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - sheet.Name = "Sales"; - - // Headers - sheet.Range["A1"].Value2 = "Region"; - sheet.Range["B1"].Value2 = "Product"; - sheet.Range["C1"].Value2 = "Amount"; - sheet.Range["D1"].Value2 = "Date"; - - // Sample data - sheet.Range["A2"].Value2 = "North"; - sheet.Range["B2"].Value2 = "Widget"; - sheet.Range["C2"].Value2 = 100; - sheet.Range["D2"].Value2 = new DateTime(2025, 1, 15); - - sheet.Range["A3"].Value2 = "South"; - sheet.Range["B3"].Value2 = "Gadget"; - sheet.Range["C3"].Value2 = 250; - sheet.Range["D3"].Value2 = new DateTime(2025, 2, 20); - - sheet.Range["A4"].Value2 = "East"; - sheet.Range["B4"].Value2 = "Widget"; - sheet.Range["C4"].Value2 = 150; - sheet.Range["D4"].Value2 = new DateTime(2025, 3, 10); - - sheet.Range["A5"].Value2 = "West"; - sheet.Range["B5"].Value2 = "Gadget"; - sheet.Range["C5"].Value2 = 300; - sheet.Range["D5"].Value2 = new DateTime(2025, 1, 25); - - return 0; - }); - - // Create table from range A1:D5 - _tableCommands.Create(batch, "Sales", "SalesTable", "A1:D5", true, TableStylePresets.Medium2); - - batch.Save(); - return testFile; - } -} - -/// -/// Results of Table creation (exposed by fixture for validation tests) -/// -public class TableCreationResult -{ - /// - public bool Success { get; set; } - /// - public bool FileCreated { get; set; } - /// - public int TablesCreated { get; set; } - /// - public double CreationTimeSeconds { get; set; } - /// - public string? ErrorMessage { get; set; } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Helpers/VbaTestsFixture.cs b/tests/ExcelMcp.Core.Tests/Helpers/VbaTestsFixture.cs deleted file mode 100644 index 241f7de3..00000000 --- a/tests/ExcelMcp.Core.Tests/Helpers/VbaTestsFixture.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System.Runtime.CompilerServices; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Helpers; - -/// -/// Fixture for VBA tests providing efficient test file creation. -/// Each test gets a unique .xlsm file via CreateTestFile() method. -/// All files use .xlsm extension for VBA compatibility. -/// -public class VbaTestsFixture : IAsyncLifetime -{ - private readonly string _tempDir; - - /// - /// Temp directory for all test files (auto-cleaned on disposal) - /// - public string TempDir => _tempDir; - - public VbaTestsFixture() - { - _tempDir = Path.Join(Path.GetTempPath(), $"VbaTests_{Guid.NewGuid():N}"); - Directory.CreateDirectory(_tempDir); - } - - /// - public Task InitializeAsync() - { - return Task.CompletedTask; - } - - /// - public Task DisposeAsync() - { - try - { - if (Directory.Exists(_tempDir)) - { - Directory.Delete(_tempDir, recursive: true); - } - } - catch - { - // Cleanup is best-effort - } - return Task.CompletedTask; - } - - /// - /// Creates a unique empty .xlsm test file for the calling test. - /// Uses [CallerMemberName] to auto-populate the test name. - /// - /// Auto-populated from caller method name - /// Path to the unique .xlsm test file - public string CreateTestFile([CallerMemberName] string testName = "") - { - var guid = Guid.NewGuid().ToString("N")[..8]; - var testFile = Path.Join(_tempDir, $"Vba_{testName}_{guid}.xlsm"); - using var manager = new SessionManager(); - var sessionId = manager.CreateSessionForNewFile(testFile, show: false); - manager.CloseSession(sessionId, save: true); - return testFile; - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Helpers/WindowTestsFixture.cs b/tests/ExcelMcp.Core.Tests/Helpers/WindowTestsFixture.cs deleted file mode 100644 index b271d71e..00000000 --- a/tests/ExcelMcp.Core.Tests/Helpers/WindowTestsFixture.cs +++ /dev/null @@ -1,58 +0,0 @@ -// -// Copyright (c) Stephan Brenner. All rights reserved. -// - -using System.Runtime.CompilerServices; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Helpers; - -/// -/// Shared fixture for WindowCommandsTests. -/// Each test creates its own file via CreateTestFile(). -/// -public class WindowTestsFixture : IAsyncLifetime -{ - private readonly string _tempDir; - - public WindowTestsFixture() - { - _tempDir = Path.Combine(Path.GetTempPath(), $"WindowTests_{Guid.NewGuid():N}"); - Directory.CreateDirectory(_tempDir); - } - - /// - /// Creates a unique test file for a test. - /// - public string CreateTestFile([CallerMemberName] string testName = "") - { - var fileName = $"{testName}_{Guid.NewGuid():N}.xlsx"; - var filePath = Path.Combine(_tempDir, fileName); - - using var manager = new SessionManager(); - var sessionId = manager.CreateSessionForNewFile(filePath, show: false); - manager.CloseSession(sessionId, save: true); - - return filePath; - } - - public Task InitializeAsync() => Task.CompletedTask; - - public Task DisposeAsync() - { - try - { - if (Directory.Exists(_tempDir)) - { - Directory.Delete(_tempDir, recursive: true); - } - } - catch - { - // Ignore cleanup errors - } - - return Task.CompletedTask; - } -} diff --git a/tests/ExcelMcp.Core.Tests/InspectTemplate.csx b/tests/ExcelMcp.Core.Tests/InspectTemplate.csx deleted file mode 100644 index 77ed9464..00000000 --- a/tests/ExcelMcp.Core.Tests/InspectTemplate.csx +++ /dev/null @@ -1,36 +0,0 @@ -using System; -using System.IO; -using System.Threading.Tasks; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands; - -var templatePath = Path.Combine(Directory.GetCurrentDirectory(), "tests", "ExcelMcp.Core.Tests", "bin", "Debug", "net10.0", "TestAssets", "DataModelTemplate.xlsx"); - -if (!File.Exists(templatePath)) -{ - Console.WriteLine($"Template not found: {templatePath}"); - return 1; -} - -Console.WriteLine($"Inspecting template: {templatePath}"); - -var dataModelCommands = new DataModelCommands(); -await using var batch = await ExcelSession.BeginBatchAsync(templatePath); - -// List tables -var tablesResult = await dataModelCommands.ListTablesAsync(batch); -Console.WriteLine($"\nTables ({tablesResult.Tables.Count}):"); -foreach (var table in tablesResult.Tables) -{ - Console.WriteLine($" - {table.Name}"); -} - -// List measures -var measuresResult = await dataModelCommands.ListMeasuresAsync(batch); -Console.WriteLine($"\nMeasures ({measuresResult.Measures.Count}):"); -foreach (var measure in measuresResult.Measures) -{ - Console.WriteLine($" - {measure.Name} (Table: {measure.TableName})"); -} - -return 0; diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Connection/ConnectionCommandsTests.BackgroundQueryRegression.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Connection/ConnectionCommandsTests.BackgroundQueryRegression.cs deleted file mode 100644 index d146068e..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Connection/ConnectionCommandsTests.BackgroundQueryRegression.cs +++ /dev/null @@ -1,104 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Connection; - -/// -/// Regression tests for the BackgroundQuery CPU spin fix. -/// -/// Root cause: RefreshWorkbookConnection originally forced BackgroundQuery = true before -/// calling connection.Refresh(). With BackgroundQuery = true, connection.Refresh() returns -/// immediately (async mode). The STA thread then polls connection.Refreshing with -/// Thread.Sleep(200). Because OleMessageFilter is registered on the STA thread, -/// COM events from Excel during the background refresh cause MsgWaitForMultipleObjectsEx -/// to wake Thread.Sleep immediately — turning the polling loop into a 100% CPU spin -/// lasting the full duration of the refresh. -/// -/// Fix: Force BackgroundQuery = false before calling connection.Refresh() so it blocks -/// the STA thread synchronously until done. The polling loop then exits immediately with -/// zero iterations (connection.Refreshing is already false on return). -/// -[Trait("Category", "Integration")] -[Trait("Speed", "Medium")] -[Trait("Layer", "Core")] -[Trait("Feature", "Connection")] -[Trait("RequiresExcel", "true")] -public partial class ConnectionCommandsTests -{ - /// - /// Regression test: BackgroundQuery must be preserved (restored) after refresh. - /// When BackgroundQuery is true, the fix temporarily sets it to false (sync refresh), - /// then restores it. This test verifies the restore actually happens. - /// - [Fact] - public void Refresh_BackgroundQueryTrue_RestoredAfterRefresh() - { - var (testFile, sourceWorkbook, connectionName) = SetupAceOleDbConnection(); - - try - { - using var batch = ExcelSession.BeginBatch(testFile); - _commands.LoadTo(batch, connectionName, "ProductsData"); - - // Verify BackgroundQuery starts as true (set by ConnectionTestHelper) - var preBefore = _commands.GetProperties(batch, connectionName); - Assert.True(preBefore.BackgroundQuery, "Precondition: BackgroundQuery should be true before refresh."); - - // Act — refresh must complete without a CPU spin - _commands.Refresh(batch, connectionName); - - // Assert — BackgroundQuery must be restored to its original value (true) - var propsAfter = _commands.GetProperties(batch, connectionName); - Assert.True(propsAfter.BackgroundQuery, - "BackgroundQuery must be restored to true after refresh. " + - "If this is false, the fix broke the save/restore logic."); - } - finally - { - if (System.IO.File.Exists(sourceWorkbook)) - { - System.IO.File.Delete(sourceWorkbook); - } - } - } - - /// - /// Regression test: BackgroundQuery=false connections must also be preserved. - /// The fix forces false during refresh; this verifies that a connection that - /// starts with false is NOT accidentally changed to true after refresh. - /// - [Fact] - public void Refresh_BackgroundQueryFalse_RemainsAfterRefresh() - { - var (testFile, sourceWorkbook, connectionName) = SetupAceOleDbConnection(); - - try - { - using var batch = ExcelSession.BeginBatch(testFile); - _commands.LoadTo(batch, connectionName, "ProductsData"); - - // Change BackgroundQuery to false before the test - _commands.SetProperties(batch, connectionName, backgroundQuery: false); - - var propsBefore = _commands.GetProperties(batch, connectionName); - Assert.False(propsBefore.BackgroundQuery, "Precondition: BackgroundQuery should be false."); - - // Act — refresh - _commands.Refresh(batch, connectionName); - - // Assert — BackgroundQuery must remain false - var propsAfter = _commands.GetProperties(batch, connectionName); - Assert.False(propsAfter.BackgroundQuery, - "BackgroundQuery must remain false after refresh. " + - "If this is true, the fix accidentally set BackgroundQuery to true."); - } - finally - { - if (System.IO.File.Exists(sourceWorkbook)) - { - System.IO.File.Delete(sourceWorkbook); - } - } - } -} diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Connection/ConnectionCommandsTests.Create.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Connection/ConnectionCommandsTests.Create.cs deleted file mode 100644 index 86312e0f..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Connection/ConnectionCommandsTests.Create.cs +++ /dev/null @@ -1,160 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Connection; - -/// -/// Tests for Connection Create operations. -/// -/// Connection Type Support: -/// - TEXT/WEB: Blocked (NotSupportedException) - Use Power Query for file/web imports -/// - OLEDB: Supported for providers installed on the machine (ACE, SQL Server, etc.) -/// - ODBC: Create works (even without valid DSN configured) -/// -public partial class ConnectionCommandsTests -{ - [Fact] - public void Create_TextConnection_ThrowsNotSupportedException() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - var csvPath = Path.Combine(_fixture.TempDir, "test_data.csv"); - System.IO.File.WriteAllText(csvPath, "Name,Value\nTest,123"); - - string connectionString = $"TEXT;{csvPath}"; - string connectionName = "TestTextConnection"; - - // Act & Assert - TEXT connections are blocked, use Power Query instead - using var batch = ExcelSession.BeginBatch(testFile); - var exception = Assert.Throws(() => - _commands.Create(batch, connectionName, connectionString)); - - Assert.Contains("TEXT and WEB connections are no longer supported", exception.Message); - Assert.Contains("powerquery", exception.Message); - } - - [Fact] - public void Create_WebConnection_ThrowsNotSupportedException() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - string connectionString = "URL;https://example.com/data.xml"; - string connectionName = "TestWebConnection"; - - // Act & Assert - WEB connections are blocked, use Power Query instead - using var batch = ExcelSession.BeginBatch(testFile); - var exception = Assert.Throws(() => - _commands.Create(batch, connectionName, connectionString)); - - Assert.Contains("TEXT and WEB connections are no longer supported", exception.Message); - Assert.Contains("powerquery", exception.Message); - } - - [Fact] - public void Create_AceOleDbConnection_ReturnsSuccess() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - var sourceWorkbook = _fixture.GetSourceFilePath("AceOleDbSource"); - AceOleDbTestHelper.CreateExcelDataSource(sourceWorkbook); - - string connectionString = AceOleDbTestHelper.GetExcelConnectionString(sourceWorkbook); - string commandText = AceOleDbTestHelper.GetDefaultCommandText(); - string connectionName = "AceOleDbConnection"; - - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - _commands.Create(batch, connectionName, connectionString, commandText: commandText); - - var listResult = _commands.List(batch); - Assert.True(listResult.Success); - Assert.Contains(listResult.Connections, c => c.Name == connectionName); - - // Cleanup source workbook (connection is stored in target workbook) - batch.Save(); - if (System.IO.File.Exists(sourceWorkbook)) - { - System.IO.File.Delete(sourceWorkbook); - } - } - - [Fact] - public void Create_OdbcConnection_ReturnsSuccess() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - // ODBC connection string - Excel accepts but may not connect without actual DSN - string connectionString = "ODBC;DSN=Excel Files;DBQ=C:\\temp\\test.xlsx"; - string connectionName = "TestOdbcConnection"; - - // Act - using var batch = ExcelSession.BeginBatch(testFile); - _commands.Create(batch, connectionName, connectionString); - - // Verify connection exists - var listResult = _commands.List(batch); - Assert.True(listResult.Success); - Assert.Contains(listResult.Connections, c => c.Name == connectionName); - } - - [Fact] - public void Create_DuplicateName_CreatesSecondConnection() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - string connectionString1 = "ODBC;DSN=Source1;DBQ=C:\\temp\\test1.xlsx"; - string connectionString2 = "ODBC;DSN=Source2;DBQ=C:\\temp\\test2.xlsx"; - string connectionName = "DuplicateTest"; - - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - Create first connection - _commands.Create(batch, connectionName, connectionString1); - - // Act - Create second connection with same name - _commands.Create(batch, connectionName, connectionString2); - - // Assert - Excel may prevent duplicates OR auto-rename the second one - var listResult = _commands.List(batch); - Assert.True(listResult.Success); - - var matchingConnections = listResult.Connections.Where(c => - c.Name == connectionName || c.Name.StartsWith(connectionName, StringComparison.Ordinal)).ToList(); - - // At least one connection should exist - Assert.True(matchingConnections.Count >= 1, - "At least one connection with the specified name should exist"); - } - - [Fact] - public void Create_WithDescription_CreatesConnection() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - string connectionString = "ODBC;DSN=Excel Files;DBQ=C:\\temp\\test.xlsx"; - string connectionName = "ConnectionWithDescription"; - string description = "This is a test connection for ODBC data"; - - // Act - using var batch = ExcelSession.BeginBatch(testFile); - _commands.Create(batch, connectionName, connectionString, - commandText: null, description: description); - - // Verify connection was created successfully - var viewResult = _commands.View(batch, connectionName); - Assert.True(viewResult.Success); - // Note: Description not currently exposed in ConnectionViewResult, only ConnectionString - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Connection/ConnectionCommandsTests.Delete.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Connection/ConnectionCommandsTests.Delete.cs deleted file mode 100644 index eb893a60..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Connection/ConnectionCommandsTests.Delete.cs +++ /dev/null @@ -1,405 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.PowerQuery; -using Xunit; -using IOFile = System.IO.File; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Connection; - -/// -/// Tests for Connection Delete operations -/// -public partial class ConnectionCommandsTests -{ - [Fact] - public void Delete_ExistingTextConnection_ReturnsSuccess() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - // Use ODBC connection (doesn't need actual DSN for delete test) - string connectionString = "ODBC;DSN=TestDSN;DBQ=C:\\temp\\test.xlsx"; - string connectionName = "DeleteTestConnection"; - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create connection first - _commands.Create(batch, connectionName, connectionString); - - // Verify connection exists - var listResultBefore = _commands.List(batch); - Assert.True(listResultBefore.Success); - Assert.Contains(listResultBefore.Connections, c => c.Name == connectionName); - - // Act - Delete the connection - // Assert - _commands.Delete(batch, connectionName); - - // Verify connection no longer exists - var listResultAfter = _commands.List(batch); - Assert.True(listResultAfter.Success); - Assert.DoesNotContain(listResultAfter.Connections, c => c.Name == connectionName); - } - - [Fact] - public void Delete_NonExistentConnection_ThrowsException() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - string connectionName = "NonExistentConnection"; - - using var batch = ExcelSession.BeginBatch(testFile); - - // Act & Assert - Attempting to delete non-existent connection should throw - var exception = Assert.Throws(() => - { - _commands.Delete(batch, connectionName); - }); - - Assert.Contains("not found", exception.Message); - } - - [Fact] - public void Delete_AfterCreatingMultiple_RemovesOnlySpecified() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - // Use ODBC connections (don't need actual DSNs for delete test) - string conn1Name = "Connection1"; - string conn2Name = "Connection2"; - string conn3Name = "Connection3"; - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create three connections - _commands.Create(batch, conn1Name, "ODBC;DSN=TestDSN1;DBQ=C:\\temp\\test1.xlsx"); - _commands.Create(batch, conn2Name, "ODBC;DSN=TestDSN2;DBQ=C:\\temp\\test2.xlsx"); - _commands.Create(batch, conn3Name, "ODBC;DSN=TestDSN3;DBQ=C:\\temp\\test3.xlsx"); - - // Act - Delete only the second connection - // Assert - _commands.Delete(batch, conn2Name); - - // Verify only conn2 is deleted - var listResult = _commands.List(batch); - Assert.True(listResult.Success); - Assert.Contains(listResult.Connections, c => c.Name == conn1Name); - Assert.DoesNotContain(listResult.Connections, c => c.Name == conn2Name); - Assert.Contains(listResult.Connections, c => c.Name == conn3Name); - } - - [Fact] - public void Delete_ConnectionWithDescription_RemovesSuccessfully() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - string connectionName = "DescribedConnection"; - string description = "Test connection with description"; - string connectionString = "ODBC;DSN=DescribedDSN;DBQ=C:\\temp\\described.xlsx"; - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create connection with description - _commands.Create(batch, connectionName, connectionString, null, description); - - // Act - Delete connection - // Assert - _commands.Delete(batch, connectionName); - - var listResult = _commands.List(batch); - Assert.DoesNotContain(listResult.Connections, c => c.Name == connectionName); - } - - [Fact] - public void Delete_ImmediatelyAfterCreate_WorksCorrectly() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - string connectionName = "ImmediateDeleteTest"; - string connectionString = "ODBC;DSN=ImmediateDSN;DBQ=C:\\temp\\immediate.xlsx"; - - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - Create and immediately delete - _commands.Create(batch, connectionName, connectionString); - - // Assert - _commands.Delete(batch, connectionName); - - var listResult = _commands.List(batch); - Assert.DoesNotContain(listResult.Connections, c => c.Name == connectionName); - } - - [Fact] - public void Delete_ConnectionAfterViewOperation_RemovesSuccessfully() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - string connectionName = "ViewThenDelete"; - string connectionString = "ODBC;DSN=ViewDeleteDSN;DBQ=C:\\temp\\viewdelete.xlsx"; - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create and view connection - _commands.Create(batch, connectionName, connectionString); - - var viewResult = _commands.View(batch, connectionName); - Assert.True(viewResult.Success); - Assert.Equal(connectionName, viewResult.ConnectionName); - - // Act - Delete after viewing - // Assert - _commands.Delete(batch, connectionName); - - var listResult = _commands.List(batch); - Assert.DoesNotContain(listResult.Connections, c => c.Name == connectionName); - } - - [Fact] - public void Delete_EmptyConnectionName_ThrowsException() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Act & Assert - Empty connection name should throw - var exception = Assert.Throws(() => - { - _commands.Delete(batch, string.Empty); - }); - - Assert.Contains("not found", exception.Message); - } - - [Fact] - public void Delete_RepeatedDeleteAttempts_SecondAttemptFails() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - string connectionName = "DoubleDeleteTest"; - string connectionString = "ODBC;DSN=DoubleDeleteDSN;DBQ=C:\\temp\\doubledelete.xlsx"; - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create connection - _commands.Create(batch, connectionName, connectionString); - - // Act - First delete - _commands.Delete(batch, connectionName); - - // Act & Assert - Second delete should fail - var exception = Assert.Throws(() => - { - _commands.Delete(batch, connectionName); - }); - - Assert.Contains("not found", exception.Message); - } - - #region Orphaned Power Query Connection Tests - - /// - /// Tests that orphaned Power Query connections (generic names like "Connection", "Connection1") - /// can be deleted via the connection API even though they use the Mashup provider. - /// These connections don't follow the standard "Query - {name}" pattern. - /// - [Fact] - public void Delete_OrphanedPowerQueryConnection_GenericName_Succeeds() - { - // Arrange - Use the test file that has orphaned connections - var sourceFile = Path.Combine(AppContext.BaseDirectory, "TestData", "MSXI Baseline.xlsx"); - - if (!IOFile.Exists(sourceFile)) - { - // Skip if test data file doesn't exist - return; - } - - var testFile = Path.Combine(_fixture.TempDir, $"OrphanedPQ_{Guid.NewGuid():N}.xlsx"); - IOFile.Copy(sourceFile, testFile); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Verify the orphaned connection exists - var listBefore = _commands.List(batch); - var orphanedConn = listBefore.Connections.FirstOrDefault(c => c.Name == "Connection"); - Assert.NotNull(orphanedConn); - Assert.True(orphanedConn.IsPowerQuery, "Connection should be detected as Power Query"); - - // Act - Delete the orphaned connection - _commands.Delete(batch, "Connection"); - - // Assert - Connection should be removed - var listAfter = _commands.List(batch); - Assert.DoesNotContain(listAfter.Connections, c => c.Name == "Connection"); - } - - /// - /// Tests that a Power Query connection following the "Query - {name}" pattern - /// but with no corresponding query can be deleted. - /// - [Fact] - public void Delete_OrphanedPowerQueryConnection_StandardNameMissingQuery_Succeeds() - { - // Arrange - Use the test file that has orphaned connections - var sourceFile = Path.Combine(AppContext.BaseDirectory, "TestData", "MSXI Baseline.xlsx"); - - if (!IOFile.Exists(sourceFile)) - { - // Skip if test data file doesn't exist - return; - } - - var testFile = Path.Combine(_fixture.TempDir, $"OrphanedPQ2_{Guid.NewGuid():N}.xlsx"); - IOFile.Copy(sourceFile, testFile); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Verify the orphaned connection exists (Query - 2 ReservationYearsBaseline has no matching query) - var listBefore = _commands.List(batch); - var orphanedConn = listBefore.Connections.FirstOrDefault(c => c.Name == "Query - 2 ReservationYearsBaseline"); - Assert.NotNull(orphanedConn); - Assert.True(orphanedConn.IsPowerQuery, "Connection should be detected as Power Query"); - - // Act - Delete the orphaned connection - _commands.Delete(batch, "Query - 2 ReservationYearsBaseline"); - - // Assert - Connection should be removed - var listAfter = _commands.List(batch); - Assert.DoesNotContain(listAfter.Connections, c => c.Name == "Query - 2 ReservationYearsBaseline"); - } - - /// - /// Tests that a valid Power Query connection (with matching query) cannot be deleted - /// via the connection API - should redirect to powerquery. - /// - [Fact] - public void Delete_ValidPowerQueryConnection_ThrowsWithRedirect() - { - // Arrange - Use the test file that has valid Power Query connections - var sourceFile = Path.Combine(AppContext.BaseDirectory, "TestData", "MSXI Baseline.xlsx"); - - if (!IOFile.Exists(sourceFile)) - { - // Skip if test data file doesn't exist - return; - } - - var testFile = Path.Combine(_fixture.TempDir, $"ValidPQ_{Guid.NewGuid():N}.xlsx"); - IOFile.Copy(sourceFile, testFile); - - using var batch = ExcelSession.BeginBatch(testFile); - - // "Query - Milestones" has a matching query named "Milestones" - var listBefore = _commands.List(batch); - var validConn = listBefore.Connections.FirstOrDefault(c => c.Name == "Query - Milestones"); - Assert.NotNull(validConn); - Assert.True(validConn.IsPowerQuery, "Connection should be detected as Power Query"); - - // Act & Assert - Should throw with redirect message - var exception = Assert.Throws(() => - { - _commands.Delete(batch, "Query - Milestones"); - }); - - Assert.Contains("powerquery", exception.Message); - } - - /// - /// Verifies that IsOrphanedPowerQueryConnection correctly identifies orphaned connections. - /// - [Fact] - public void IsOrphanedPowerQueryConnection_GenericNamedConnection_ReturnsTrue() - { - // Arrange - Use the test file that has orphaned connections - var sourceFile = Path.Combine(AppContext.BaseDirectory, "TestData", "MSXI Baseline.xlsx"); - - if (!IOFile.Exists(sourceFile)) - { - // Skip if test data file doesn't exist - return; - } - - var testFile = Path.Combine(_fixture.TempDir, $"IsOrphaned_{Guid.NewGuid():N}.xlsx"); - IOFile.Copy(sourceFile, testFile); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - Check if generic-named connections are orphaned - var result = batch.Execute((ctx, ct) => - { - dynamic? conn = null; - try - { - // "Connection" is a generic-named Power Query connection - conn = ctx.Book.Connections["Connection"]; - return PowerQueryHelpers.IsOrphanedPowerQueryConnection(ctx.Book, conn); - } - finally - { - if (conn != null) - { - System.Runtime.InteropServices.Marshal.ReleaseComObject(conn); - } - } - }); - - // Assert - Generic-named Power Query connections are always orphaned - Assert.True(result); - } - - /// - /// Verifies that IsOrphanedPowerQueryConnection correctly identifies valid connections. - /// - [Fact] - public void IsOrphanedPowerQueryConnection_ValidConnection_ReturnsFalse() - { - // Arrange - Use the test file that has valid Power Query connections - var sourceFile = Path.Combine(AppContext.BaseDirectory, "TestData", "MSXI Baseline.xlsx"); - - if (!IOFile.Exists(sourceFile)) - { - // Skip if test data file doesn't exist - return; - } - - var testFile = Path.Combine(_fixture.TempDir, $"IsValid_{Guid.NewGuid():N}.xlsx"); - IOFile.Copy(sourceFile, testFile); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - Check if "Query - Milestones" (has matching query) is orphaned - var result = batch.Execute((ctx, ct) => - { - dynamic? conn = null; - try - { - // "Query - Milestones" has a matching "Milestones" query - conn = ctx.Book.Connections["Query - Milestones"]; - return PowerQueryHelpers.IsOrphanedPowerQueryConnection(ctx.Book, conn); - } - finally - { - if (conn != null) - { - System.Runtime.InteropServices.Marshal.ReleaseComObject(conn); - } - } - }); - - // Assert - This is NOT orphaned because the query exists - Assert.False(result); - } - - #endregion -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Connection/ConnectionCommandsTests.List.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Connection/ConnectionCommandsTests.List.cs deleted file mode 100644 index ba3f97e2..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Connection/ConnectionCommandsTests.List.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Connection; - -/// -/// Tests for Connection List operations -/// -public partial class ConnectionCommandsTests -{ - [Fact] - public void List_EmptyWorkbook_ReturnsSuccessWithEmptyList() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - // Act - using var batch = ExcelSession.BeginBatch(testFile); - var result = _commands.List(batch); - - // Assert - Assert.True(result.Success, $"List failed: {result.ErrorMessage}"); - Assert.NotNull(result.Connections); - Assert.Empty(result.Connections); - Assert.Equal(testFile, result.FilePath); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Connection/ConnectionCommandsTests.Refresh.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Connection/ConnectionCommandsTests.Refresh.cs deleted file mode 100644 index 95cff33c..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Connection/ConnectionCommandsTests.Refresh.cs +++ /dev/null @@ -1,100 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Connection; - -[Trait("Category", "Integration")] -[Trait("Speed", "Medium")] -[Trait("Layer", "Core")] -[Trait("Feature", "Connection")] -[Trait("RequiresExcel", "true")] -public partial class ConnectionCommandsTests -{ - [Fact] - public void Refresh_ConnectionNotFound_ThrowsException() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - // Act & Assert - using var batch = ExcelSession.BeginBatch(testFile); - var exception = Assert.Throws(() => _commands.Refresh(batch, "NonExistentConnection")); - Assert.Contains("not found", exception.Message); - } - - /// - /// Tests refreshing an ACE OLEDB connection bound to an Excel workbook data source. - /// - [Fact] - public void Refresh_AceOleDbConnection_ReturnsSuccess() - { - var (testFile, sourceWorkbook, connectionName) = SetupAceOleDbConnection(); - - try - { - using var batch = ExcelSession.BeginBatch(testFile); - - _commands.LoadTo(batch, connectionName, "ProductsData"); - - _commands.Refresh(batch, connectionName); - } - finally - { - if (System.IO.File.Exists(sourceWorkbook)) - { - System.IO.File.Delete(sourceWorkbook); - } - } - } - - /// - /// Tests refreshing an ACE OLEDB connection after modifying the external workbook. - /// - [Fact] - public void Refresh_AceOleDbConnectionAfterDataUpdate_ReturnsSuccess() - { - var (testFile, sourceWorkbook, connectionName) = SetupAceOleDbConnection(); - - try - { - using (var batch = ExcelSession.BeginBatch(testFile)) - { - _commands.LoadTo(batch, connectionName, "ProductsData"); - batch.Save(); - } - - AceOleDbTestHelper.UpdateExcelDataSource(sourceWorkbook, sheet => - { - sheet.Range["B2"].Value2 = 49.99; - }); - - using var refreshBatch = ExcelSession.BeginBatch(testFile); - _commands.Refresh(refreshBatch, connectionName); - } - finally - { - if (System.IO.File.Exists(sourceWorkbook)) - { - System.IO.File.Delete(sourceWorkbook); - } - } - } - - private (string testFile, string sourceWorkbook, string connectionName) SetupAceOleDbConnection() - { - var testFile = _fixture.CreateTestFile(); - - var sourceWorkbook = _fixture.GetSourceFilePath("AceOleDbSource"); - AceOleDbTestHelper.CreateExcelDataSource(sourceWorkbook); - - var connectionName = "TestAceOleDbConnection"; - ConnectionTestHelper.CreateAceOleDbConnection(testFile, connectionName, sourceWorkbook); - - return (testFile, sourceWorkbook, connectionName); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Connection/ConnectionCommandsTests.RefreshTimeout.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Connection/ConnectionCommandsTests.RefreshTimeout.cs deleted file mode 100644 index df9e10a6..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Connection/ConnectionCommandsTests.RefreshTimeout.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Reflection; -using Sbroenne.ExcelMcp.Core.Commands; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Connection; - -[Trait("Category", "Integration")] -[Trait("Speed", "Fast")] -[Trait("Layer", "Core")] -[Trait("Feature", "Connection")] -public partial class ConnectionCommandsTests -{ - [Fact] - public void RefreshWait_WhenCancellationRequested_InvokesCancelActionAndThrows() - { - MethodInfo waitMethod = GetRefreshWaitMethod(); - - bool cancelCalled = false; - using var cts = new CancellationTokenSource(); - - var cancellationThread = new Thread(() => - { - Thread.Sleep(50); - cts.Cancel(); - }); - cancellationThread.Start(); - - try - { - var exception = Assert.Throws(() => - waitMethod.Invoke(null, - [ - (Func)(() => true), - (Action)(() => cancelCalled = true), - cts.Token - ])); - - Assert.IsType(exception.InnerException); - Assert.True(cancelCalled); - } - finally - { - cancellationThread.Join(); - } - } - - [Fact] - public void RefreshWait_WhenRefreshCompletes_DoesNotInvokeCancelAction() - { - MethodInfo waitMethod = GetRefreshWaitMethod(); - - int pollCount = 0; - bool cancelCalled = false; - - waitMethod.Invoke(null, - [ - (Func)(() => Interlocked.Increment(ref pollCount) == 1), - (Action)(() => cancelCalled = true), - CancellationToken.None - ]); - - Assert.True(pollCount >= 2); - Assert.False(cancelCalled); - } - - private static MethodInfo GetRefreshWaitMethod() - { - var waitMethod = typeof(ConnectionCommands).GetMethod( - "WaitForConnectionRefreshCompletion", - BindingFlags.NonPublic | BindingFlags.Static); - - return waitMethod ?? throw new InvalidOperationException( - "Expected private method WaitForConnectionRefreshCompletion was not found."); - } -} diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Connection/ConnectionCommandsTests.View.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Connection/ConnectionCommandsTests.View.cs deleted file mode 100644 index 86d2f51b..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Connection/ConnectionCommandsTests.View.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Connection; - -/// -/// Tests for Connection View/Properties operations -/// -public partial class ConnectionCommandsTests -{ - [Fact] - public void View_ExistingConnection_ReturnsDetails() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - // Use ODBC connection (doesn't need actual DSN for view test) - var connName = "ViewTestConnection"; - string connectionString = "ODBC;DSN=ViewTestDSN;DBQ=C:\\temp\\viewtest.xlsx"; - ConnectionTestHelper.CreateOdbcConnection(testFile, connName, connectionString); - - // Act - using var batch = ExcelSession.BeginBatch(testFile); - var result = _commands.View(batch, connName); - - // Assert - Assert.True(result.Success, $"View failed: {result.ErrorMessage}"); - Assert.Equal(connName, result.ConnectionName); - Assert.NotNull(result.ConnectionString); - Assert.NotNull(result.Type); - } - - [Fact] - public void View_NonExistentConnection_ThrowsException() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - // Act & Assert - using var batch = ExcelSession.BeginBatch(testFile); - var exception = Assert.Throws(() => _commands.View(batch, "NonExistent")); - Assert.Contains("not found", exception.Message, StringComparison.OrdinalIgnoreCase); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Connection/ConnectionCommandsTests.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Connection/ConnectionCommandsTests.cs deleted file mode 100644 index 3686d950..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Connection/ConnectionCommandsTests.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Sbroenne.ExcelMcp.Core.Commands; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Connection; - -/// -/// Comprehensive integration tests for ConnectionCommands. -/// Tests all connection operations with batch API pattern. -/// Uses ConnectionTestsFixture for efficient test file creation. -/// -[Trait("Category", "Integration")] -[Trait("Speed", "Medium")] -[Trait("Layer", "Core")] -[Trait("Feature", "Connections")] -[Trait("RequiresExcel", "true")] -public partial class ConnectionCommandsTests(ConnectionTestsFixture fixture) : IClassFixture -{ - private readonly ConnectionCommands _commands = new(); - private readonly ConnectionTestsFixture _fixture = fixture; -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/DataModel/DataModelCommandsTests.DaxFormatting.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/DataModel/DataModelCommandsTests.DaxFormatting.cs deleted file mode 100644 index adbfcef4..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/DataModel/DataModelCommandsTests.DaxFormatting.cs +++ /dev/null @@ -1,187 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.DataModel; - -/// -/// Integration tests for DAX formatting feature. -/// Tests verify that DAX formulas are automatically formatted on write operations. -/// Write operations (CreateMeasure, UpdateMeasure) format DAX via daxformatter.com API. -/// Read operations (ListMeasures, Read) return raw DAX as stored in the Data Model. -/// -[Collection("DataModel")] -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("RequiresExcel", "true")] -[Trait("Feature", "DataModel")] -[Trait("Speed", "Slow")] -public class DataModelCommandsTests_DaxFormatting -{ - private readonly DataModelCommands _dataModelCommands; - private readonly string _dataModelFile; - - public DataModelCommandsTests_DaxFormatting(DataModelPivotTableFixture fixture) - { - _dataModelCommands = new DataModelCommands(); - _dataModelFile = fixture.TestFilePath; - } - - /// - /// Tests that ListMeasures returns raw DAX previews (no formatting on read). - /// Verifies that formula previews are returned as stored in the Data Model. - /// - [Fact] - public async Task ListMeasures_WithMeasures_ReturnsRawPreviews() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = await _dataModelCommands.ListMeasures(batch); - - Assert.True(result.Success, $"ListMeasures failed: {result.ErrorMessage}"); - Assert.NotEmpty(result.Measures); - - // Check that previews are returned (raw DAX, not formatted) - var totalSalesMeasure = result.Measures.FirstOrDefault(m => m.Name == "Total Sales"); - Assert.NotNull(totalSalesMeasure); - Assert.NotEmpty(totalSalesMeasure.FormulaPreview); - Assert.Contains("SUM", totalSalesMeasure.FormulaPreview, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Tests that Read returns raw DAX formula as stored (no formatting on read). - /// Verifies that the formula is returned intact. - /// - [Fact] - public async Task Read_WithMeasure_ReturnsRawFormula() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = await _dataModelCommands.Read(batch, "Total Sales"); - - Assert.True(result.Success, $"Read failed: {result.ErrorMessage}"); - Assert.NotEmpty(result.DaxFormula); - Assert.Contains("SUM", result.DaxFormula, StringComparison.OrdinalIgnoreCase); - - // CharacterCount should reflect the formula length - Assert.True(result.CharacterCount > 0); - Assert.Equal(result.DaxFormula.Length, result.CharacterCount); - } - - /// - /// Tests that CreateMeasure formats DAX before saving to Excel. - /// Verifies that the measure can be created and retrieved successfully. - /// - [Fact] - public async Task CreateMeasure_WithUnformattedDax_SavesFormattedVersion() - { - var measureName = $"Test_CreateFormatted_{Guid.NewGuid():N}"; - // Unformatted DAX (single line, no spaces around operators) - var unformattedDax = "CALCULATE(SUM(SalesTable[Amount]),FILTER(SalesTable,SalesTable[CustomerID]=1))"; - - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - // Create measure (should format automatically) - _ = _dataModelCommands.CreateMeasure(batch, "SalesTable", measureName, unformattedDax); - - // Retrieve and verify - var viewResult = await _dataModelCommands.Read(batch, measureName); - Assert.True(viewResult.Success, $"Read failed: {viewResult.ErrorMessage}"); - Assert.Contains("CALCULATE", viewResult.DaxFormula, StringComparison.OrdinalIgnoreCase); - Assert.Contains("SUM", viewResult.DaxFormula, StringComparison.OrdinalIgnoreCase); - - // The formatted version might have newlines or extra spaces - // but should still contain the core function names - Assert.NotEmpty(viewResult.DaxFormula); - } - - /// - /// Tests that UpdateMeasure formats DAX before saving to Excel. - /// Verifies that the measure can be updated and retrieved successfully. - /// - [Fact] - public async Task UpdateMeasure_WithUnformattedDax_SavesFormattedVersion() - { - var measureName = $"Test_UpdateFormatted_{Guid.NewGuid():N}"; - var originalFormula = "SUM(SalesTable[Amount])"; - // Unformatted DAX for update (single line, no spaces) - var unformattedUpdate = "CALCULATE(AVERAGE(SalesTable[Amount]),FILTER(SalesTable,SalesTable[Region]=\"North\"))"; - - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - // Create measure - _ = _dataModelCommands.CreateMeasure(batch, "SalesTable", measureName, originalFormula); - - // Update with unformatted DAX (should format automatically) - _ = _dataModelCommands.UpdateMeasure(batch, measureName, daxFormula: unformattedUpdate); - - // Retrieve and verify - var viewResult = await _dataModelCommands.Read(batch, measureName); - Assert.True(viewResult.Success, $"Read failed: {viewResult.ErrorMessage}"); - Assert.Contains("CALCULATE", viewResult.DaxFormula, StringComparison.OrdinalIgnoreCase); - Assert.Contains("AVERAGE", viewResult.DaxFormula, StringComparison.OrdinalIgnoreCase); - - // Should NOT contain SUM (since we updated the formula) - Assert.DoesNotContain("SUM", viewResult.DaxFormula, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Tests that formatted DAX still executes correctly in Excel. - /// Creates a measure with formatted DAX and verifies it can be used in a PivotTable. - /// - [Fact] - public async Task CreateMeasure_WithFormattedDax_ExecutesCorrectlyInExcel() - { - var measureName = $"Test_ExecuteFormatted_{Guid.NewGuid():N}"; - // Pre-formatted DAX (with newlines and indentation) - var formattedDax = @"CALCULATE( - SUM(SalesTable[Amount]), - FILTER( - SalesTable, - SalesTable[CustomerID] = 1 - ) -)"; - - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - // Create measure with pre-formatted DAX - _ = _dataModelCommands.CreateMeasure(batch, "SalesTable", measureName, formattedDax); - - // Retrieve and verify it was saved correctly - var viewResult = await _dataModelCommands.Read(batch, measureName); - Assert.True(viewResult.Success, $"Read failed: {viewResult.ErrorMessage}"); - Assert.Contains("CALCULATE", viewResult.DaxFormula, StringComparison.OrdinalIgnoreCase); - - // Verify the measure appears in the list - var listResult = await _dataModelCommands.ListMeasures(batch); - Assert.Contains(listResult.Measures, m => m.Name == measureName); - } - - /// - /// Tests that null or empty DAX is handled gracefully (no formatting attempted). - /// - [Fact] - public async Task UpdateMeasure_WithNullDaxFormula_DoesNotAttemptFormatting() - { - var measureName = $"Test_NullFormula_{Guid.NewGuid():N}"; - var originalFormula = "SUM(SalesTable[Amount])"; - var newDescription = "Updated description"; - - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - // Create measure - _ = _dataModelCommands.CreateMeasure(batch, "SalesTable", measureName, originalFormula); - - // Update only description (null daxFormula should not trigger formatting) - _ = _dataModelCommands.UpdateMeasure(batch, measureName, daxFormula: null, description: newDescription); - - // Verify description updated, formula unchanged - var viewResult = await _dataModelCommands.Read(batch, measureName); - Assert.True(viewResult.Success, $"Read failed: {viewResult.ErrorMessage}"); - Assert.Equal(newDescription, viewResult.Description); - Assert.Contains("SUM", viewResult.DaxFormula, StringComparison.OrdinalIgnoreCase); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/DataModel/DataModelCommandsTests.DaxLocale.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/DataModel/DataModelCommandsTests.DaxLocale.cs deleted file mode 100644 index a579d8f1..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/DataModel/DataModelCommandsTests.DaxLocale.cs +++ /dev/null @@ -1,136 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.DataModel; - -/// -/// Integration tests for DAX formula locale translation. -/// Tests that DAX formulas with US comma separators work correctly on all locales. -/// -public partial class DataModelCommandsTests -{ - #region DAX Locale Translation Tests - - /// - /// Tests that DAX formulas with function argument separators (commas) are handled correctly. - /// This is the exact formula from the user's bug report where DATEADD arguments were corrupted. - /// LLM use case: "create a measure with DATEADD function" - /// - [Fact] - public async Task CreateMeasure_DateAddFormula_CreatesSuccessfully() - { - // This is the formula that was failing - comma was becoming period on European locales - var measureName = $"Test_DATEADD_{Guid.NewGuid():N}"; - var daxFormula = "CALCULATE([Total Sales], DATEADD(SalesTable[Date], -1, MONTH))"; - - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - // This should NOT throw - the DaxFormulaTranslator should handle locale conversion - _ = _dataModelCommands.CreateMeasure(batch, "SalesTable", measureName, daxFormula); - - // Verify measure was created - var listResult = await _dataModelCommands.ListMeasures(batch); - Assert.Contains(listResult.Measures, m => m.Name == measureName); - - // Verify the formula was stored (content may vary by locale but should be valid DAX) - var readResult = await _dataModelCommands.Read(batch, measureName); - Assert.True(readResult.Success, $"Read measure failed: {readResult.ErrorMessage}"); - Assert.NotNull(readResult.DaxFormula); - Assert.Contains("DATEADD", readResult.DaxFormula, StringComparison.OrdinalIgnoreCase); - Assert.Contains("CALCULATE", readResult.DaxFormula, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Tests nested function calls with multiple comma separators. - /// LLM use case: "create a complex DAX measure with nested functions" - /// - [Fact] - public async Task CreateMeasure_NestedFunctions_CreatesSuccessfully() - { - var measureName = $"Test_Nested_{Guid.NewGuid():N}"; - // Complex formula with multiple nested functions and comma separators - var daxFormula = "CALCULATE(SUM(SalesTable[Amount]), FILTER(ALL(SalesTable), SalesTable[Amount] > 100))"; - - using var batch = ExcelSession.BeginBatch(_dataModelFile); - _ = _dataModelCommands.CreateMeasure(batch, "SalesTable", measureName, daxFormula); - - // Verify measure was created and formula is valid - var readResult = await _dataModelCommands.Read(batch, measureName); - Assert.True(readResult.Success, $"Read measure failed: {readResult.ErrorMessage}"); - Assert.Contains("CALCULATE", readResult.DaxFormula, StringComparison.OrdinalIgnoreCase); - Assert.Contains("FILTER", readResult.DaxFormula, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Tests updating a measure with a complex DAX formula containing function separators. - /// LLM use case: "update this measure's formula to use DATESINPERIOD" - /// - [Fact] - public async Task UpdateMeasure_ComplexDaxFormula_UpdatesSuccessfully() - { - var measureName = $"Test_Update_{Guid.NewGuid():N}"; - var originalFormula = "SUM(SalesTable[Amount])"; - // Rolling 3-month formula with multiple comma separators - var updatedFormula = "AVERAGEX(DATESINPERIOD(SalesTable[Date], MAX(SalesTable[Date]), -3, MONTH), SalesTable[Amount])"; - - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - // Create measure with simple formula - _ = _dataModelCommands.CreateMeasure(batch, "SalesTable", measureName, originalFormula); - - // Update with complex formula - should handle locale conversion - _ = _dataModelCommands.UpdateMeasure(batch, measureName, daxFormula: updatedFormula); - - // Verify the formula was updated - var readResult = await _dataModelCommands.Read(batch, measureName); - Assert.True(readResult.Success, $"Read measure failed: {readResult.ErrorMessage}"); - Assert.Contains("AVERAGEX", readResult.DaxFormula, StringComparison.OrdinalIgnoreCase); - Assert.Contains("DATESINPERIOD", readResult.DaxFormula, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Tests DAX formula with string literals containing commas - commas inside strings should NOT be translated. - /// LLM use case: "create a measure that checks for a specific text value" - /// - [Fact] - public async Task CreateMeasure_StringLiteralWithComma_PreservesStringContent() - { - var measureName = $"Test_String_{Guid.NewGuid():N}"; - // Formula with comma inside a string literal - this comma should NOT be translated - var daxFormula = "IF(SalesTable[Region] = \"North, South\", 1, 0)"; - - using var batch = ExcelSession.BeginBatch(_dataModelFile); - _ = _dataModelCommands.CreateMeasure(batch, "SalesTable", measureName, daxFormula); - - // Verify measure was created - var readResult = await _dataModelCommands.Read(batch, measureName); - Assert.True(readResult.Success, $"Read measure failed: {readResult.ErrorMessage}"); - Assert.Contains("IF", readResult.DaxFormula, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Tests simple DAX formula without function separators - should work unchanged. - /// LLM use case: "create a simple SUM measure" - /// - [Fact] - public async Task CreateMeasure_SimpleFormula_CreatesSuccessfully() - { - var measureName = $"Test_Simple_{Guid.NewGuid():N}"; - var daxFormula = "SUM(SalesTable[Amount])"; - - using var batch = ExcelSession.BeginBatch(_dataModelFile); - _ = _dataModelCommands.CreateMeasure(batch, "SalesTable", measureName, daxFormula); - - var readResult = await _dataModelCommands.Read(batch, measureName); - Assert.True(readResult.Success, $"Read measure failed: {readResult.ErrorMessage}"); - Assert.Contains("SUM", readResult.DaxFormula, StringComparison.OrdinalIgnoreCase); - } - - #endregion -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/DataModel/DataModelCommandsTests.DeleteTable.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/DataModel/DataModelCommandsTests.DeleteTable.cs deleted file mode 100644 index d678db91..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/DataModel/DataModelCommandsTests.DeleteTable.cs +++ /dev/null @@ -1,155 +0,0 @@ -// Copyright (c) Sbroenne. All rights reserved. -// Licensed under the MIT License. - -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands; -using Sbroenne.ExcelMcp.Core.Commands.Table; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.DataModel; - -/// -/// Integration tests for DeleteTable operation. -/// Uses isolated test files since DeleteTable is destructive and would affect shared fixtures. -/// -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("RequiresExcel", "true")] -[Trait("Feature", "DataModel")] -[Trait("Speed", "Slow")] -public class DataModelDeleteTableTests : IClassFixture -{ - private readonly DataModelCommands _dataModelCommands; - private readonly TableCommands _tableCommands; - private readonly FileCommands _fileCommands; - private readonly DataModelTestsFixture _fixture; - - public DataModelDeleteTableTests(DataModelTestsFixture fixture) - { - _dataModelCommands = new DataModelCommands(); - _tableCommands = new TableCommands(); - _fileCommands = new FileCommands(); - _fixture = fixture; - } - - /// - /// Creates a test file with a Data Model table that can be deleted. - /// - private string CreateTestFileWithDataModelTable(string testName) - { - var testFile = _fixture.CreateTestFile(testName); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create a simple table with data - batch.Execute((ctx, ct) => - { - dynamic? sheet = null; - dynamic? range = null; - dynamic? listObject = null; - try - { - dynamic sheets = ctx.Book.Worksheets; - sheet = sheets[1]; - - // Headers and data - sheet.Range["A1"].Value2 = "ID"; - sheet.Range["B1"].Value2 = "Value"; - sheet.Range["A2"].Value2 = 1; - sheet.Range["B2"].Value2 = 100; - sheet.Range["A3"].Value2 = 2; - sheet.Range["B3"].Value2 = 200; - - // Format as Excel Table - range = sheet.Range["A1:B3"]; - listObject = sheet.ListObjects.Add( - SourceType: 1, // xlSrcRange - Source: range, - XlListObjectHasHeaders: 1 // xlYes - ); - listObject.Name = "TestTable"; - } - finally - { - ComInterop.ComUtilities.Release(ref listObject); - ComInterop.ComUtilities.Release(ref range); - ComInterop.ComUtilities.Release(ref sheet); - } - return 0; - }); - - // Add table to Data Model - _tableCommands.AddToDataModel(batch, "TestTable"); - - batch.Save(); - - return testFile; - } - - /// - /// Tests deleting a table from the Data Model. - /// LLM use case: "delete orphaned table from data model" - /// - [Fact] - public async Task DeleteTable_ExistingTable_RemovesFromDataModel() - { - // Arrange - var testFile = CreateTestFileWithDataModelTable(nameof(DeleteTable_ExistingTable_RemovesFromDataModel)); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Verify table exists in Data Model - var listBefore = await _dataModelCommands.ListTables(batch); - Assert.True(listBefore.Success); - Assert.Contains(listBefore.Tables, t => t.Name == "TestTable"); - - // Act - Delete the table from Data Model - _ = _dataModelCommands.DeleteTable(batch, "TestTable"); - - // Assert - Table should be gone from Data Model - var listAfter = await _dataModelCommands.ListTables(batch); - Assert.True(listAfter.Success); - Assert.DoesNotContain(listAfter.Tables, t => t.Name == "TestTable"); - } - - /// - /// Tests that DeleteTable throws when table doesn't exist. - /// - [Fact] - public void DeleteTable_NonExistentTable_ThrowsInvalidOperationException() - { - // Arrange - var testFile = CreateTestFileWithDataModelTable(nameof(DeleteTable_NonExistentTable_ThrowsInvalidOperationException)); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Act & Assert - var ex = Assert.Throws(() => - _dataModelCommands.DeleteTable(batch, "NonExistentTable")); - - Assert.Contains("not found", ex.Message, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Tests that DeleteTable throws when Data Model has no tables. - /// - [Fact] - public void DeleteTable_EmptyDataModel_ThrowsInvalidOperationException() - { - // Arrange - Create file without Data Model - var testFile = _fixture.CreateTestFile(); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Act & Assert - var ex = Assert.Throws(() => - _dataModelCommands.DeleteTable(batch, "AnyTable")); - - Assert.Contains("no tables", ex.Message, StringComparison.OrdinalIgnoreCase); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/DataModel/DataModelCommandsTests.Dmv.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/DataModel/DataModelCommandsTests.Dmv.cs deleted file mode 100644 index 3062f423..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/DataModel/DataModelCommandsTests.Dmv.cs +++ /dev/null @@ -1,305 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.DataModel; - -/// -/// Integration tests for DMV (Dynamic Management View) query execution. -/// Tests verify that DMV queries can be executed against the Data Model's embedded -/// Analysis Services engine and return tabular metadata results. -/// -[Collection("DataModel")] -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("RequiresExcel", "true")] -[Trait("Feature", "DataModel")] -[Trait("Speed", "Slow")] -public class DataModelCommandsTests_Dmv -{ - private readonly DataModelCommands _dataModelCommands; - private readonly string _dataModelFile; - - public DataModelCommandsTests_Dmv(DataModelPivotTableFixture fixture) - { - _dataModelCommands = new DataModelCommands(); - _dataModelFile = fixture.TestFilePath; - } - - #region Basic DMV Query Tests - - /// - /// Tests that TMSCHEMA_TABLES DMV returns table metadata. - /// Note: Excel's embedded Analysis Services may return 0 rows for this DMV. - /// LLM use case: "show me all tables in the Data Model" - /// - [Fact] - public void ExecuteDmv_TmschemaTablesQuery_ReturnsSchemaWithoutError() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = _dataModelCommands.ExecuteDmv(batch, "SELECT * FROM $SYSTEM.TMSCHEMA_TABLES"); - - Assert.True(result.Success, $"ExecuteDmv failed: {result.ErrorMessage}"); - Assert.NotNull(result.Columns); - Assert.NotNull(result.Rows); - // Note: Excel's embedded AS may return 0 rows for TMSCHEMA_TABLES - // Just verify the query executes without error - - // TMSCHEMA_TABLES should have ID and Name columns if any results - if (result.ColumnCount > 0) - { - Assert.Contains(result.Columns, c => c.Equals("ID", StringComparison.OrdinalIgnoreCase)); - Assert.Contains(result.Columns, c => c.Equals("Name", StringComparison.OrdinalIgnoreCase)); - } - } - - /// - /// Tests that TMSCHEMA_COLUMNS DMV returns column metadata. - /// Note: Excel's embedded Analysis Services may return 0 rows for this DMV. - /// LLM use case: "show me all columns in the Data Model" - /// - [Fact] - public void ExecuteDmv_TmschemaColumnsQuery_ReturnsSchemaWithoutError() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = _dataModelCommands.ExecuteDmv(batch, "SELECT * FROM $SYSTEM.TMSCHEMA_COLUMNS"); - - Assert.True(result.Success, $"ExecuteDmv failed: {result.ErrorMessage}"); - Assert.NotNull(result.Columns); - Assert.NotNull(result.Rows); - // Note: Excel's embedded AS may return 0 rows for TMSCHEMA_COLUMNS - // Just verify the query executes without error - - // TMSCHEMA_COLUMNS should have TableID and ExplicitName columns if any results - if (result.ColumnCount > 0) - { - Assert.Contains(result.Columns, c => c.Equals("TableID", StringComparison.OrdinalIgnoreCase)); - Assert.Contains(result.Columns, c => c.Equals("ExplicitName", StringComparison.OrdinalIgnoreCase)); - } - } - - /// - /// Tests that TMSCHEMA_MEASURES DMV returns measure metadata. - /// LLM use case: "list all measures in the Data Model" - /// - [Fact] - public void ExecuteDmv_TmschemaMeasuresQuery_ReturnsMeasures() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = _dataModelCommands.ExecuteDmv(batch, "SELECT * FROM $SYSTEM.TMSCHEMA_MEASURES"); - - Assert.True(result.Success, $"ExecuteDmv failed: {result.ErrorMessage}"); - Assert.NotNull(result.Columns); - Assert.NotNull(result.Rows); - // Note: May have 0 rows if no measures defined, but columns should exist - - // TMSCHEMA_MEASURES should have standard columns - Assert.Contains(result.Columns, c => c.Equals("Name", StringComparison.OrdinalIgnoreCase)); - Assert.Contains(result.Columns, c => c.Equals("Expression", StringComparison.OrdinalIgnoreCase)); - } - - /// - /// Tests that TMSCHEMA_RELATIONSHIPS DMV returns relationship metadata. - /// LLM use case: "show me all relationships in the Data Model" - /// - [Fact] - public void ExecuteDmv_TmschemaRelationshipsQuery_ReturnsRelationships() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = _dataModelCommands.ExecuteDmv(batch, "SELECT * FROM $SYSTEM.TMSCHEMA_RELATIONSHIPS"); - - Assert.True(result.Success, $"ExecuteDmv failed: {result.ErrorMessage}"); - Assert.NotNull(result.Columns); - Assert.NotNull(result.Rows); - // Note: May have 0 rows if no relationships defined, but columns should exist - - // TMSCHEMA_RELATIONSHIPS should have FromTableID and ToTableID columns - Assert.Contains(result.Columns, c => c.Equals("FromTableID", StringComparison.OrdinalIgnoreCase)); - Assert.Contains(result.Columns, c => c.Equals("ToTableID", StringComparison.OrdinalIgnoreCase)); - } - - #endregion - - #region Filtered DMV Query Tests - - /// - /// Tests DMV query with WHERE clause filter. - /// Note: Excel's embedded Analysis Services has limited DMV support. - /// Uses DISCOVER_CALC_DEPENDENCY which is known to work. - /// LLM use case: "show me calculation dependencies" - /// - [Fact] - public void ExecuteDmv_DiscoverDependencyQuery_ReturnsResults() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - // DISCOVER_CALC_DEPENDENCY is known to work in Excel's embedded AS - var result = _dataModelCommands.ExecuteDmv(batch, - "SELECT * FROM $SYSTEM.DISCOVER_CALC_DEPENDENCY"); - - Assert.True(result.Success, $"Query failed: {result.ErrorMessage}"); - Assert.NotNull(result.Columns); - Assert.NotNull(result.Rows); - // DISCOVER_CALC_DEPENDENCY returns columns about calculation dependencies - } - - /// - /// Tests that SELECT * queries work (Excel's embedded AS doesn't support column selection). - /// LLM use case: "query all columns from a DMV" - /// - [Fact] - public void ExecuteDmv_SelectAllQuery_Works() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - // SELECT * works - specific column selection doesn't work in Excel's embedded AS - var result = _dataModelCommands.ExecuteDmv(batch, "SELECT * FROM $SYSTEM.DBSCHEMA_CATALOGS"); - - Assert.True(result.Success, $"ExecuteDmv failed: {result.ErrorMessage}"); - Assert.NotNull(result.Columns); - Assert.True(result.ColumnCount > 0, "Expected columns from DBSCHEMA_CATALOGS"); - } - - #endregion - - #region Advanced DMV Query Tests - - /// - /// Tests DISCOVER_CALC_DEPENDENCY DMV for dependency analysis. - /// LLM use case: "show me measure dependencies" - /// - [Fact] - public void ExecuteDmv_DiscoverCalcDependencyQuery_ReturnsDependencies() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = _dataModelCommands.ExecuteDmv(batch, "SELECT * FROM $SYSTEM.DISCOVER_CALC_DEPENDENCY"); - - Assert.True(result.Success, $"ExecuteDmv failed: {result.ErrorMessage}"); - Assert.NotNull(result.Columns); - Assert.NotNull(result.Rows); - // Note: May have 0 rows if no calculated dependencies, but columns should exist - - // DISCOVER_CALC_DEPENDENCY should have standard columns - Assert.Contains(result.Columns, c => c.Equals("OBJECT", StringComparison.OrdinalIgnoreCase) || - c.Equals("OBJECT_TYPE", StringComparison.OrdinalIgnoreCase)); - } - - /// - /// Tests DBSCHEMA_CATALOGS DMV for catalog info. - /// LLM use case: "show me database catalogs" - /// - [Fact] - public void ExecuteDmv_DbschemaCatalogsQuery_ReturnsCatalogs() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = _dataModelCommands.ExecuteDmv(batch, "SELECT * FROM $SYSTEM.DBSCHEMA_CATALOGS"); - - Assert.True(result.Success, $"ExecuteDmv failed: {result.ErrorMessage}"); - Assert.NotNull(result.Columns); - Assert.NotNull(result.Rows); - Assert.True(result.RowCount > 0, "Expected at least one catalog"); - - Assert.Contains(result.Columns, c => c.Equals("CATALOG_NAME", StringComparison.OrdinalIgnoreCase)); - } - - #endregion - - #region Error Handling Tests - - /// - /// Tests that invalid DMV query throws exception. - /// LLM use case: handling syntax errors - /// - [Fact] - public void ExecuteDmv_InvalidQuery_ThrowsException() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - // Invalid DMV - non-existent system view - var ex = Assert.ThrowsAny(() => - _dataModelCommands.ExecuteDmv(batch, "SELECT * FROM $SYSTEM.NONEXISTENT_VIEW")); - - Assert.NotNull(ex); - } - - /// - /// Tests that null/empty query throws ArgumentException. - /// - [Fact] - public void ExecuteDmv_NullQuery_ThrowsArgumentException() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - var ex = Assert.Throws(() => - _dataModelCommands.ExecuteDmv(batch, "")); - - Assert.Contains("dmvQuery", ex.Message); - } - - /// - /// Tests that whitespace-only query throws ArgumentException. - /// - [Fact] - public void ExecuteDmv_WhitespaceQuery_ThrowsArgumentException() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - var ex = Assert.Throws(() => - _dataModelCommands.ExecuteDmv(batch, " ")); - - Assert.Contains("dmvQuery", ex.Message); - } - - /// - /// Tests that malformed SQL query throws exception. - /// - [Fact] - public void ExecuteDmv_MalformedSqlQuery_ThrowsException() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - var ex = Assert.ThrowsAny(() => - _dataModelCommands.ExecuteDmv(batch, "INVALID SQL SYNTAX HERE")); - - Assert.NotNull(ex); - } - - #endregion - - #region Query Result Validation Tests - - /// - /// Tests that DMV query result includes proper DmvQuery echo. - /// - [Fact] - public void ExecuteDmv_ValidQuery_EchoesQueryInResult() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var query = "SELECT * FROM $SYSTEM.TMSCHEMA_TABLES"; - var result = _dataModelCommands.ExecuteDmv(batch, query); - - Assert.True(result.Success, $"ExecuteDmv failed: {result.ErrorMessage}"); - Assert.Equal(query, result.DmvQuery); - } - - /// - /// Tests that RowCount and ColumnCount match actual data. - /// - [Fact] - public void ExecuteDmv_ValidQuery_CountsMatchActualData() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = _dataModelCommands.ExecuteDmv(batch, "SELECT * FROM $SYSTEM.TMSCHEMA_TABLES"); - - Assert.True(result.Success, $"ExecuteDmv failed: {result.ErrorMessage}"); - Assert.Equal(result.Columns.Count, result.ColumnCount); - Assert.Equal(result.Rows.Count, result.RowCount); - } - - #endregion -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/DataModel/DataModelCommandsTests.Evaluate.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/DataModel/DataModelCommandsTests.Evaluate.cs deleted file mode 100644 index ebce4965..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/DataModel/DataModelCommandsTests.Evaluate.cs +++ /dev/null @@ -1,260 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.DataModel; - -/// -/// Integration tests for DAX EVALUATE query execution. -/// Tests verify that DAX EVALUATE queries can be executed against the Data Model -/// and return tabular results via the ADO connection. -/// -[Collection("DataModel")] -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("RequiresExcel", "true")] -[Trait("Feature", "DataModel")] -[Trait("Speed", "Slow")] -public class DataModelCommandsTests_Evaluate -{ - private readonly DataModelCommands _dataModelCommands; - private readonly string _dataModelFile; - - public DataModelCommandsTests_Evaluate(DataModelPivotTableFixture fixture) - { - _dataModelCommands = new DataModelCommands(); - _dataModelFile = fixture.TestFilePath; - } - - #region Basic EVALUATE Tests - - /// - /// Tests that a simple EVALUATE query returns table data. - /// LLM use case: "show me all rows from this Data Model table" - /// - [Fact] - public void Evaluate_SimpleTableQuery_ReturnsRows() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = _dataModelCommands.Evaluate(batch, "EVALUATE 'SalesTable'"); - - Assert.True(result.Success, $"Evaluate failed: {result.ErrorMessage}"); - Assert.NotNull(result.Columns); - Assert.NotNull(result.Rows); - Assert.True(result.RowCount > 0, "Expected at least one row"); - Assert.True(result.ColumnCount > 0, "Expected at least one column"); - - // Column names include table prefix (e.g., "SalesTable[CustomerID]") - Assert.True(result.Columns.Any(c => c.Contains("CustomerID", StringComparison.OrdinalIgnoreCase)), - $"Expected a column containing 'CustomerID', got: {string.Join(", ", result.Columns)}"); - Assert.True(result.Columns.Any(c => c.Contains("Amount", StringComparison.OrdinalIgnoreCase)), - $"Expected a column containing 'Amount', got: {string.Join(", ", result.Columns)}"); - } - - /// - /// Tests EVALUATE with SUMMARIZE for aggregated results. - /// LLM use case: "summarize sales by customer" - /// - [Fact] - public void Evaluate_SummarizeQuery_ReturnsAggregatedData() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = _dataModelCommands.Evaluate(batch, - "EVALUATE SUMMARIZE('SalesTable', 'SalesTable'[CustomerID], \"TotalAmount\", SUM('SalesTable'[Amount]))"); - - Assert.True(result.Success, $"Evaluate failed: {result.ErrorMessage}"); - Assert.NotNull(result.Columns); - Assert.NotNull(result.Rows); - Assert.True(result.RowCount > 0, "Expected aggregated rows"); - - // Column names include table prefix - Assert.True(result.Columns.Any(c => c.Contains("CustomerID", StringComparison.OrdinalIgnoreCase)), - $"Expected a column containing 'CustomerID', got: {string.Join(", ", result.Columns)}"); - Assert.True(result.Columns.Any(c => c.Contains("Amount", StringComparison.OrdinalIgnoreCase) || - c.Contains("TotalAmount", StringComparison.OrdinalIgnoreCase)), - $"Expected a column containing 'Amount' or 'TotalAmount', got: {string.Join(", ", result.Columns)}"); - } - - /// - /// Tests EVALUATE with FILTER for filtered results. - /// LLM use case: "show me sales greater than 100" - /// - [Fact] - public void Evaluate_FilterQuery_ReturnsFilteredRows() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = _dataModelCommands.Evaluate(batch, - "EVALUATE FILTER('SalesTable', 'SalesTable'[Amount] > 100)"); - - Assert.True(result.Success, $"Evaluate failed: {result.ErrorMessage}"); - Assert.NotNull(result.Rows); - - // All returned rows should have Amount > 100 - var amountColumnIndex = result.Columns.FindIndex(c => - c.Equals("Amount", StringComparison.OrdinalIgnoreCase) || - c.EndsWith("[Amount]", StringComparison.OrdinalIgnoreCase)); - - if (amountColumnIndex >= 0 && result.Rows.Count > 0) - { - foreach (var row in result.Rows) - { - if (row[amountColumnIndex] != null) - { - var amount = Convert.ToDecimal(row[amountColumnIndex], System.Globalization.CultureInfo.InvariantCulture); - Assert.True(amount > 100, $"Expected Amount > 100, got {amount}"); - } - } - } - } - - /// - /// Tests EVALUATE with ROW for scalar results. - /// LLM use case: "calculate total sales" - /// - [Fact] - public void Evaluate_RowQuery_ReturnsSingleRow() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = _dataModelCommands.Evaluate(batch, - "EVALUATE ROW(\"TotalSales\", SUM('SalesTable'[Amount]))"); - - Assert.True(result.Success, $"Evaluate failed: {result.ErrorMessage}"); - Assert.NotNull(result.Rows); - Assert.Equal(1, result.RowCount); // ROW returns exactly one row - - // Should have one column with the computed value - Assert.Equal(1, result.ColumnCount); - } - - #endregion - - #region Error Handling Tests - - /// - /// Tests that invalid DAX query throws exception. - /// LLM use case: handling syntax errors - /// - [Fact] - public void Evaluate_InvalidDax_ThrowsException() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - // Invalid DAX syntax - throws COMException from Excel - var ex = Assert.ThrowsAny(() => - _dataModelCommands.Evaluate(batch, "EVALUATE INVALID_FUNCTION()")); - - Assert.NotNull(ex); - } - - /// - /// Tests that null/empty query throws ArgumentException. - /// - [Fact] - public void Evaluate_NullQuery_ThrowsArgumentException() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - var ex = Assert.Throws(() => - _dataModelCommands.Evaluate(batch, "")); - - Assert.Contains("daxQuery", ex.Message); - } - - /// - /// Tests that non-EVALUATE query returns error. - /// (Only EVALUATE queries return tabular results) - /// - [Fact] - public void Evaluate_NonEvaluateQuery_HandlesGracefully() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - // DEFINE without EVALUATE doesn't return data - // This might throw or return empty depending on implementation - var ex = Assert.ThrowsAny(() => - _dataModelCommands.Evaluate(batch, "DEFINE VAR x = 1")); - - // Should indicate an error occurred - Assert.NotNull(ex); - } - - #endregion - - #region Advanced Query Tests - - /// - /// Tests EVALUATE with CALCULATETABLE for context-modified results. - /// LLM use case: "show sales filtered by specific conditions" - /// - [Fact] - public void Evaluate_CalculateTableQuery_ReturnsModifiedContext() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = _dataModelCommands.Evaluate(batch, - "EVALUATE CALCULATETABLE('SalesTable', 'SalesTable'[CustomerID] = 1)"); - - Assert.True(result.Success, $"Evaluate failed: {result.ErrorMessage}"); - Assert.NotNull(result.Rows); - - // All rows should have CustomerID = 1 - var customerIdIndex = result.Columns.FindIndex(c => - c.Equals("CustomerID", StringComparison.OrdinalIgnoreCase) || - c.EndsWith("[CustomerID]", StringComparison.OrdinalIgnoreCase)); - - if (customerIdIndex >= 0 && result.Rows.Count > 0) - { - foreach (var row in result.Rows) - { - if (row[customerIdIndex] != null) - { - var customerId = Convert.ToInt32(row[customerIdIndex], System.Globalization.CultureInfo.InvariantCulture); - Assert.Equal(1, customerId); - } - } - } - } - - /// - /// Tests EVALUATE with TOPN for limited results. - /// LLM use case: "show me top 5 sales by amount" - /// - [Fact] - public void Evaluate_TopNQuery_ReturnsLimitedRows() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = _dataModelCommands.Evaluate(batch, - "EVALUATE TOPN(3, 'SalesTable', 'SalesTable'[Amount], DESC)"); - - Assert.True(result.Success, $"Evaluate failed: {result.ErrorMessage}"); - Assert.NotNull(result.Rows); - Assert.True(result.RowCount <= 3, $"Expected at most 3 rows, got {result.RowCount}"); - } - - /// - /// Tests EVALUATE with DISTINCT for unique values. - /// LLM use case: "show me unique customer IDs" - /// - [Fact] - public void Evaluate_DistinctQuery_ReturnsUniqueValues() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = _dataModelCommands.Evaluate(batch, - "EVALUATE DISTINCT('SalesTable'[CustomerID])"); - - Assert.True(result.Success, $"Evaluate failed: {result.ErrorMessage}"); - Assert.NotNull(result.Rows); - Assert.Equal(1, result.ColumnCount); // DISTINCT on single column returns single column - - // Verify all values are unique - var values = result.Rows.Select(r => r[0]).ToList(); - var uniqueValues = values.Distinct().ToList(); - Assert.Equal(values.Count, uniqueValues.Count); - } - - #endregion -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/DataModel/DataModelCommandsTests.Refresh.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/DataModel/DataModelCommandsTests.Refresh.cs deleted file mode 100644 index 161df846..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/DataModel/DataModelCommandsTests.Refresh.cs +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright (c) Sbroenne. All rights reserved. -// Licensed under the MIT License. - -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.DataModel; - -/// -/// Integration tests for Data Model Refresh operations. -/// Uses shared DataModelPivotTableFixture (non-destructive refresh). -/// -public partial class DataModelCommandsTests -{ - #region Refresh Tests - - /// - /// Refreshes the entire Data Model. - /// LLM use case: "refresh the data model" - /// - [Fact] - public void Refresh_EntireModel_Succeeds() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = _dataModelCommands.Refresh(batch); - - Assert.True(result.Success, $"Refresh entire model failed: {result.ErrorMessage}"); - Assert.Equal(_dataModelFile, result.FilePath); - } - - /// - /// Refreshes a specific Data Model table by name. - /// LLM use case: "refresh the SalesTable in the data model" - /// - [Fact] - public void Refresh_SpecificTable_Succeeds() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = _dataModelCommands.Refresh(batch, tableName: "SalesTable"); - - Assert.True(result.Success, $"Refresh specific table failed: {result.ErrorMessage}"); - Assert.Equal(_dataModelFile, result.FilePath); - } - - /// - /// Refreshing a non-existent table throws InvalidOperationException. - /// LLM use case: error handling for typo in table name - /// - [Fact] - public void Refresh_InvalidTableName_ThrowsInvalidOperationException() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - var ex = Assert.Throws( - () => _dataModelCommands.Refresh(batch, tableName: "NonExistentTable")); - - Assert.Contains("NonExistentTable", ex.Message); - } - - /// - /// Refreshing a workbook without a Data Model throws InvalidOperationException. - /// LLM use case: error handling when data model doesn't exist - /// - [Fact] - public void Refresh_NoDataModel_ThrowsInvalidOperationException() - { - // Create a fresh empty workbook (no Data Model) - var tempDir = Path.Join(Path.GetTempPath(), $"RefreshTest_{Guid.NewGuid():N}"); - Directory.CreateDirectory(tempDir); - try - { - var emptyFile = CoreTestHelper.CreateUniqueTestFile( - nameof(DataModelCommandsTests), nameof(Refresh_NoDataModel_ThrowsInvalidOperationException), - tempDir); - - using var batch = ExcelSession.BeginBatch(emptyFile); - - Assert.Throws( - () => _dataModelCommands.Refresh(batch)); - } - finally - { - try { Directory.Delete(tempDir, recursive: true); } catch { } - } - } - - /// - /// Refresh with explicit timeout succeeds when within time limit. - /// - [Fact] - public void Refresh_WithExplicitTimeout_Succeeds() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = _dataModelCommands.Refresh(batch, timeout: TimeSpan.FromMinutes(5)); - - Assert.True(result.Success, $"Refresh with timeout failed: {result.ErrorMessage}"); - } - - #endregion -} diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/DataModel/DataModelCommandsTests.RenameTable.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/DataModel/DataModelCommandsTests.RenameTable.cs deleted file mode 100644 index b0ea36d7..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/DataModel/DataModelCommandsTests.RenameTable.cs +++ /dev/null @@ -1,431 +0,0 @@ -// Copyright (c) Sbroenne. All rights reserved. -// Licensed under the MIT License. - -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands; -using Sbroenne.ExcelMcp.Core.Models; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.DataModel; - -/// -/// Integration tests for RenameTable operation in the Data Model. -/// -/// KNOWN EXCEL LIMITATION: Data Model table names (ModelTable.Name) are IMMUTABLE after creation. -/// The table name is cached from the source connection at creation time and CANNOT be changed -/// via the COM API - not through direct property assignment, Model.Refresh(), or even save/reopen. -/// -/// These tests verify: -/// 1. The implementation correctly attempts the rename via COM -/// 2. The implementation returns a clear failure when the rename cannot be performed -/// 3. Rollback preserves the original state (PQ + connection names are restored) -/// 4. Validation rules (empty names, conflicts, non-existent tables) work correctly -/// -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("RequiresExcel", "true")] -[Trait("Feature", "DataModel")] -[Trait("Speed", "Slow")] -public class DataModelRenameTableTests : IClassFixture -{ - private readonly DataModelCommands _dataModelCommands; - private readonly PowerQueryCommands _powerQueryCommands; - private readonly DataModelTestsFixture _fixture; - - public DataModelRenameTableTests(DataModelTestsFixture fixture) - { - _dataModelCommands = new DataModelCommands(); - _powerQueryCommands = new PowerQueryCommands(_dataModelCommands); - _fixture = fixture; - } - - /// - /// Creates a test file with a PQ-backed Data Model table that can be renamed. - /// PQ-backed tables are created by loading a Power Query with LoadToDataModel mode, - /// which creates a "Query - {QueryName}" connection with Microsoft.Mashup.OleDb provider. - /// - private string CreateTestFileWithDataModelTable(string testName, string tableName = "TestTable") - { - var testFile = _fixture.CreateTestFile(testName); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create a Power Query that loads to Data Model - // The M code creates a simple table with sample data - string mCode = $@"let - Source = #table( - type table [ID = Int64.Type, Value = Int64.Type, Category = text], - {{{{1, 100, ""A""}}, {{2, 200, ""B""}}, {{3, 300, ""A""}}}} - ) -in - Source"; - - // Create PQ with LoadToDataModel - this creates the "Query - {tableName}" connection - _powerQueryCommands.Create(batch, tableName, mCode, PowerQueryLoadMode.LoadToDataModel); - - batch.Save(); - - return testFile; - } - - /// - /// Creates a test file with two PQ-backed Data Model tables for conflict testing. - /// - private string CreateTestFileWithTwoDataModelTables(string testName, string table1Name = "Table1", string table2Name = "Table2") - { - var testFile = _fixture.CreateTestFile(testName); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create first Power Query → Data Model - string mCode1 = $@"let - Source = #table( - type table [ID = Int64.Type, Value = Int64.Type], - {{{{1, 100}}, {{2, 200}}}} - ) -in - Source"; - _powerQueryCommands.Create(batch, table1Name, mCode1, PowerQueryLoadMode.LoadToDataModel); - - // Create second Power Query → Data Model - string mCode2 = $@"let - Source = #table( - type table [Category = text, Name = text], - {{{{""A"", ""Alpha""}}, {{""B"", ""Beta""}}}} - ) -in - Source"; - _powerQueryCommands.Create(batch, table2Name, mCode2, PowerQueryLoadMode.LoadToDataModel); - - batch.Save(); - - return testFile; - } - - // ========================================== - // EXCEL LIMITATION CASES - // These tests verify that the implementation correctly handles - // the Excel limitation where ModelTable.Name is immutable. - // ========================================== - - /// - /// Tests that attempting to rename a PQ-backed Data Model table fails - /// with a clear error message about the Excel limitation. - /// LLM use case: "rename data model table from 'SalesData' to 'SalesTable'" - /// - [Fact] - public void RenameTable_PqBackedTable_FailsDueToExcelLimitation() - { - // Arrange - var testFile = CreateTestFileWithDataModelTable( - nameof(RenameTable_PqBackedTable_FailsDueToExcelLimitation), - "OriginalTable"); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Verify table exists in Data Model - var listBefore = _dataModelCommands.ListTables(batch); - Assert.True(listBefore.Success); - Assert.Contains(listBefore.Tables, t => t.Name == "OriginalTable"); - - // Act - var result = _dataModelCommands.RenameTable(batch, "OriginalTable", "RenamedTable"); - - // Assert - Rename fails due to Excel limitation - Assert.False(result.Success); - Assert.Contains("immutable", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); - Assert.Equal("data-model-table", result.ObjectType); - Assert.Equal("OriginalTable", result.OldName); - Assert.Equal("RenamedTable", result.NewName); - - // Verify original table is preserved (rollback worked) - var listAfter = _dataModelCommands.ListTables(batch); - Assert.True(listAfter.Success); - Assert.Contains(listAfter.Tables, t => t.Name == "OriginalTable"); - Assert.DoesNotContain(listAfter.Tables, t => t.Name == "RenamedTable"); - } - - /// - /// Tests that rename attempts with whitespace are normalized but still fail - /// due to the Excel limitation on Data Model table names. - /// - [Fact] - public void RenameTable_WithLeadingTrailingSpaces_FailsDueToExcelLimitation() - { - // Arrange - var testFile = CreateTestFileWithDataModelTable( - nameof(RenameTable_WithLeadingTrailingSpaces_FailsDueToExcelLimitation), - "TestTable"); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - rename with whitespace in new name - var result = _dataModelCommands.RenameTable(batch, " TestTable ", " TrimmedName "); - - // Assert - Names are normalized but rename fails - Assert.False(result.Success); - Assert.Contains("immutable", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); - Assert.Equal("TestTable", result.NormalizedOldName); // Normalized (trimmed) - Assert.Equal("TrimmedName", result.NormalizedNewName); // Normalized (trimmed) - - // Verify original table is preserved - var list = _dataModelCommands.ListTables(batch); - Assert.Contains(list.Tables, t => t.Name == "TestTable"); - } - - // ========================================== - // NO-OP CASES - // ========================================== - - /// - /// Tests that renaming to the same name (after trim) is a no-op success. - /// - [Fact] - public void RenameTable_SameNameAfterTrim_ReturnsNoOpSuccess() - { - // Arrange - var testFile = CreateTestFileWithDataModelTable( - nameof(RenameTable_SameNameAfterTrim_ReturnsNoOpSuccess), - "TestTable"); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - rename to same name (with extra spaces that get trimmed) - var result = _dataModelCommands.RenameTable(batch, "TestTable", " TestTable "); - - // Assert - should be success (no-op) - Assert.True(result.Success, $"No-op should succeed: {result.ErrorMessage}"); - Assert.Equal("TestTable", result.NormalizedOldName); - Assert.Equal("TestTable", result.NormalizedNewName); // Same after normalization - } - - // ========================================== - // CASE-ONLY RENAME CASES - // ========================================== - - /// - /// Tests that case-only rename also fails due to the Excel limitation. - /// Even though case-only changes are technically "the same" table, Excel - /// still cannot change the ModelTable.Name property. - /// - [Fact] - public void RenameTable_CaseOnlyChange_FailsDueToExcelLimitation() - { - // Arrange - var testFile = CreateTestFileWithDataModelTable( - nameof(RenameTable_CaseOnlyChange_FailsDueToExcelLimitation), - "testtable"); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - rename to same name with different casing - var result = _dataModelCommands.RenameTable(batch, "testtable", "TestTable"); - - // Assert - Rename fails due to Excel limitation - Assert.False(result.Success); - Assert.Contains("immutable", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); - Assert.Equal("testtable", result.OldName); - Assert.Equal("TestTable", result.NewName); - - // Verify original table is preserved - var list = _dataModelCommands.ListTables(batch); - Assert.Contains(list.Tables, t => t.Name.Equals("testtable", StringComparison.OrdinalIgnoreCase)); - } - - // ========================================== - // CONFLICT CASES - // ========================================== - - /// - /// Tests that renaming to an existing table name (case-insensitive) fails. - /// - [Fact] - public void RenameTable_ConflictWithExistingTable_ReturnsFailure() - { - // Arrange - var testFile = CreateTestFileWithTwoDataModelTables( - nameof(RenameTable_ConflictWithExistingTable_ReturnsFailure), - "SourceTable", - "TargetTable"); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - try to rename to existing table name - var result = _dataModelCommands.RenameTable(batch, "SourceTable", "TargetTable"); - - // Assert - Assert.False(result.Success); - Assert.Contains("already exists", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Tests that case-insensitive conflict detection works. - /// - [Fact] - public void RenameTable_CaseInsensitiveConflict_ReturnsFailure() - { - // Arrange - var testFile = CreateTestFileWithTwoDataModelTables( - nameof(RenameTable_CaseInsensitiveConflict_ReturnsFailure), - "SourceTable", - "TARGETTABLE"); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - try to rename with different case of existing name - var result = _dataModelCommands.RenameTable(batch, "SourceTable", "targettable"); - - // Assert - Assert.False(result.Success); - Assert.Contains("already exists", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); - } - - // ========================================== - // MISSING TABLE CASES - // ========================================== - - /// - /// Tests that renaming a non-existent table fails with clear error. - /// - [Fact] - public void RenameTable_NonExistentTable_ReturnsFailure() - { - // Arrange - var testFile = CreateTestFileWithDataModelTable( - nameof(RenameTable_NonExistentTable_ReturnsFailure), - "ExistingTable"); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - var result = _dataModelCommands.RenameTable(batch, "NonExistentTable", "NewName"); - - // Assert - Assert.False(result.Success); - Assert.Contains("not found", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Tests that renaming fails clearly when Data Model has no tables. - /// - [Fact] - public void RenameTable_EmptyDataModel_ReturnsFailure() - { - // Arrange - Create file without Data Model - var testFile = _fixture.CreateTestFile(nameof(RenameTable_EmptyDataModel_ReturnsFailure)); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - var result = _dataModelCommands.RenameTable(batch, "AnyTable", "NewName"); - - // Assert - Assert.False(result.Success); - Assert.Contains("no tables", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); - } - - // ========================================== - // INVALID NAME CASES - // ========================================== - - /// - /// Tests that empty new name fails validation. - /// - [Fact] - public void RenameTable_EmptyNewName_ReturnsFailure() - { - // Arrange - var testFile = CreateTestFileWithDataModelTable( - nameof(RenameTable_EmptyNewName_ReturnsFailure), - "TestTable"); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - var result = _dataModelCommands.RenameTable(batch, "TestTable", ""); - - // Assert - Assert.False(result.Success); - Assert.Contains("empty", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Tests that whitespace-only new name fails validation. - /// - [Fact] - public void RenameTable_WhitespaceOnlyNewName_ReturnsFailure() - { - // Arrange - var testFile = CreateTestFileWithDataModelTable( - nameof(RenameTable_WhitespaceOnlyNewName_ReturnsFailure), - "TestTable"); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - var result = _dataModelCommands.RenameTable(batch, "TestTable", " "); - - // Assert - Assert.False(result.Success); - Assert.Contains("empty", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Tests that empty old name fails validation. - /// - [Fact] - public void RenameTable_EmptyOldName_ReturnsFailure() - { - // Arrange - var testFile = CreateTestFileWithDataModelTable( - nameof(RenameTable_EmptyOldName_ReturnsFailure), - "TestTable"); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - var result = _dataModelCommands.RenameTable(batch, "", "NewName"); - - // Assert - Assert.False(result.Success); - Assert.Contains("empty", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); - } - - // ========================================== - // ROUND-TRIP PERSISTENCE TEST - // ========================================== - - /// - /// Tests that when rename fails, the original table name is preserved across save/reopen. - /// This verifies the rollback mechanism works correctly. - /// - [Fact] - public void RenameTable_FailureThenSaveAndReopen_PreservesOriginalTable() - { - // Arrange - var testFile = CreateTestFileWithDataModelTable( - nameof(RenameTable_FailureThenSaveAndReopen_PreservesOriginalTable), - "OriginalName"); - - // Act - Attempt rename (will fail), then save - using (var batch1 = ExcelSession.BeginBatch(testFile)) - { - var result = _dataModelCommands.RenameTable(batch1, "OriginalName", "PersistedName"); - Assert.False(result.Success); // Rename fails due to Excel limitation - Assert.Contains("immutable", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); - batch1.Save(); - } - - // Assert - Reopen and verify original table is preserved - using var batch2 = ExcelSession.BeginBatch(testFile); - var list = _dataModelCommands.ListTables(batch2); - Assert.True(list.Success); - Assert.Contains(list.Tables, t => t.Name == "OriginalName"); // Original preserved - Assert.DoesNotContain(list.Tables, t => t.Name == "PersistedName"); // New name not present - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/DataModel/DataModelCommandsTests.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/DataModel/DataModelCommandsTests.cs deleted file mode 100644 index 057d292b..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/DataModel/DataModelCommandsTests.cs +++ /dev/null @@ -1,330 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.DataModel; - -/// -/// Integration tests for Data Model operations focusing on LLM use cases. -/// Tests cover essential workflows: list tables/measures/relationships, create/update/delete measures, manage relationships. -/// Uses DataModelPivotTableFixture which creates ONE comprehensive Data Model + PivotTable workbook (shared via collection fixture). -/// -[Collection("DataModel")] -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("RequiresExcel", "true")] -[Trait("Feature", "DataModel")] -[Trait("Speed", "Slow")] -public partial class DataModelCommandsTests -{ - private readonly DataModelCommands _dataModelCommands; - private readonly string _dataModelFile; - private readonly DataModelPivotTableCreationResult _creationResult; - - /// - /// Initializes a new instance of the class. - /// - public DataModelCommandsTests(DataModelPivotTableFixture fixture) - { - _dataModelCommands = new DataModelCommands(); - _dataModelFile = fixture.TestFilePath; - _creationResult = fixture.CreationResult; - } - - #region Core Discovery Tests (4 tests) - - /// - /// Validates that the fixture successfully created the Data Model. - /// LLM use case: "create a data model with tables, relationships, and measures" - /// - [Fact] - public void Create_CompleteDataModel_SuccessfullyCreatesAllComponents() - { - Assert.True(_creationResult.Success, - $"Data Model creation failed: {_creationResult.ErrorMessage}"); - Assert.True(_creationResult.FileCreated); - Assert.Equal(5, _creationResult.TablesCreated); // SalesTable, CustomersTable, ProductsTable, RegionalSalesTable, DisambiguationTable - Assert.Equal(5, _creationResult.TablesLoadedToModel); - Assert.Equal(2, _creationResult.RelationshipsCreated); - Assert.Equal(6, _creationResult.MeasuresCreated); // Total Sales, Average Sale, Total Customers, TotalRevenue, ACR, Discount - } - - /// - /// Tests listing tables in the data model. - /// LLM use case: "show me all tables in the data model" - /// - [Fact] - public async Task ListTables_WithDataModel_ReturnsTables() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = await _dataModelCommands.ListTables(batch); - - Assert.True(result.Success, $"ListTables failed: {result.ErrorMessage}"); - Assert.Equal(5, result.Tables.Count); // Now includes RegionalSalesTable and DisambiguationTable - Assert.Contains(result.Tables, t => t.Name == "SalesTable"); - Assert.Contains(result.Tables, t => t.Name == "CustomersTable"); - Assert.Contains(result.Tables, t => t.Name == "ProductsTable"); - Assert.Contains(result.Tables, t => t.Name == "RegionalSalesTable"); - Assert.Contains(result.Tables, t => t.Name == "DisambiguationTable"); - } - - /// - /// Tests getting table details with columns. - /// LLM use case: "show me the columns in this data model table" - /// - [Fact] - public async Task GetTable_WithValidTable_ReturnsCompleteInfo() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = await _dataModelCommands.ReadTable(batch, "SalesTable"); - - Assert.True(result.Success, $"ViewTable failed: {result.ErrorMessage}"); - Assert.Equal("SalesTable", result.TableName); - Assert.NotNull(result.SourceName); - Assert.True(result.RecordCount >= 10); - Assert.NotNull(result.Columns); - Assert.True(result.Columns.Count >= 6); - } - - /// - /// Tests getting data model statistics. - /// LLM use case: "show me information about this data model" - /// - [Fact] - public async Task GetInfo_WithRealisticDataModel_ReturnsAccurateStatistics() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = await _dataModelCommands.ReadInfo(batch); - - Assert.True(result.Success, $"GetModelInfo failed: {result.ErrorMessage}"); - Assert.Equal(5, result.TableCount); // Now includes RegionalSalesTable and DisambiguationTable - Assert.Equal(6, result.MeasureCount); // Now includes TotalRevenue, ACR, Discount - Assert.Equal(2, result.RelationshipCount); - Assert.True(result.TotalRows > 0); - Assert.NotNull(result.TableNames); - Assert.Contains("SalesTable", result.TableNames); - } - - #endregion - - #region Measure Operations (5 tests) - - /// - /// Tests listing all measures in the data model. - /// LLM use case: "show me all DAX measures" - /// - [Fact] - public async Task ListMeasures_WithRealisticDataModel_ReturnsMeasuresWithFormulas() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = await _dataModelCommands.ListMeasures(batch); - - Assert.True(result.Success, $"ListMeasures failed: {result.ErrorMessage}"); - Assert.NotNull(result.Measures); - Assert.Equal(6, result.Measures.Count); // Now includes TotalRevenue, ACR, Discount - - var measureNames = result.Measures.Select(m => m.Name).ToList(); - Assert.Contains("Total Sales", measureNames); - Assert.Contains("Average Sale", measureNames); - Assert.Contains("Total Customers", measureNames); - Assert.Contains("TotalRevenue", measureNames); - Assert.Contains("ACR", measureNames); - Assert.Contains("Discount", measureNames); - } - - /// - /// Tests viewing a specific measure's DAX formula. - /// LLM use case: "show me the DAX formula for this measure" - /// - [Fact] - public async Task Get_WithRealisticDataModel_ReturnsValidDAXFormula() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = await _dataModelCommands.Read(batch, "Total Sales"); - - Assert.True(result.Success, $"ViewMeasure failed: {result.ErrorMessage}"); - Assert.NotNull(result.DaxFormula); - Assert.Contains("SUM", result.DaxFormula, StringComparison.OrdinalIgnoreCase); - Assert.Contains("Amount", result.DaxFormula); - Assert.Equal("Total Sales", result.MeasureName); - } - - /// - /// Tests that Read returns structured FormatInfo with type and properties. - /// Regression test: FormatString was previously a string, now it's a structured FormatInfo object. - /// - [Fact] - public async Task Read_WithMeasure_ReturnsStructuredFormatInfo() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = await _dataModelCommands.Read(batch, "Total Sales"); - - Assert.True(result.Success, $"Read failed: {result.ErrorMessage}"); - - // FormatInfo should be populated (not null) - Assert.NotNull(result.FormatInfo); - - // Type should be a known format type - var validTypes = new HashSet { "General", "Currency", "Decimal", "Percentage", "WholeNumber" }; - Assert.Contains(result.FormatInfo.Type, validTypes); - } - - /// - /// Tests creating a new DAX measure. - /// LLM use case: "create a DAX measure" - /// - [Fact] - public async Task CreateMeasure_ValidNameAndFormula_CreatesSuccessfully() - { - var measureName = $"Test_{nameof(CreateMeasure_ValidNameAndFormula_CreatesSuccessfully)}_{Guid.NewGuid():N}"; - var daxFormula = "SUM(SalesTable[Amount])"; - - using var batch = ExcelSession.BeginBatch(_dataModelFile); - _ = _dataModelCommands.CreateMeasure(batch, "SalesTable", measureName, daxFormula); // CreateMeasure throws on error - - // Verify measure created - var listResult = await _dataModelCommands.ListMeasures(batch); - Assert.Contains(listResult.Measures, m => m.Name == measureName); - } - - /// - /// Tests updating an existing measure's DAX formula. - /// LLM use case: "update this measure's formula" - /// - [Fact] - public async Task UpdateMeasure_WithValidFormula_UpdatesSuccessfully() - { - var measureName = $"Test_{nameof(UpdateMeasure_WithValidFormula_UpdatesSuccessfully)}_{Guid.NewGuid():N}"; - var originalFormula = "SUM(SalesTable[Amount])"; - var updatedFormula = "AVERAGE(SalesTable[Amount])"; - - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - // Create measure - _ = _dataModelCommands.CreateMeasure(batch, "SalesTable", measureName, originalFormula); // CreateMeasure throws on error - - // Update formula - _ = _dataModelCommands.UpdateMeasure(batch, measureName, daxFormula: updatedFormula); // UpdateMeasure throws on error - - // Verify update - var viewResult = await _dataModelCommands.Read(batch, measureName); - Assert.Contains("AVERAGE", viewResult.DaxFormula, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Tests deleting a measure. - /// LLM use case: "delete this DAX measure" - /// - [Fact] - public async Task DeleteMeasure_WithValidMeasure_ReturnsSuccessResult() - { - var measureName = $"Test_{nameof(DeleteMeasure_WithValidMeasure_ReturnsSuccessResult)}_{Guid.NewGuid():N}"; - - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - // Create measure - _ = _dataModelCommands.CreateMeasure(batch, "SalesTable", measureName, "SUM(SalesTable[Amount])"); // CreateMeasure throws on error - - // Delete measure - _ = _dataModelCommands.DeleteMeasure(batch, measureName); // DeleteMeasure throws on error - - // Verify deletion - var listResult = await _dataModelCommands.ListMeasures(batch); - Assert.DoesNotContain(listResult.Measures, m => m.Name == measureName); - } - - #endregion - - #region Relationship Operations (3 tests) - - /// - /// Tests listing all relationships in the data model. - /// LLM use case: "show me all table relationships" - /// - [Fact] - public async Task ListRelationships_WithRealisticDataModel_ReturnsRelationshipsWithDetails() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = await _dataModelCommands.ListRelationships(batch); - - Assert.True(result.Success, $"ListRelationships failed: {result.ErrorMessage}"); - Assert.NotNull(result.Relationships); - Assert.Equal(2, result.Relationships.Count); - - // Verify SalesTable->CustomersTable relationship - var salesCustomersRel = result.Relationships.FirstOrDefault(r => - r.FromTable == "SalesTable" && r.ToTable == "CustomersTable"); - Assert.NotNull(salesCustomersRel); - Assert.Equal("CustomerID", salesCustomersRel.FromColumn); - Assert.Equal("CustomerID", salesCustomersRel.ToColumn); - Assert.True(salesCustomersRel.IsActive); - - // Verify SalesTable->ProductsTable relationship - var salesProductsRel = result.Relationships.FirstOrDefault(r => - r.FromTable == "SalesTable" && r.ToTable == "ProductsTable"); - Assert.NotNull(salesProductsRel); - Assert.Equal("ProductID", salesProductsRel.FromColumn); - Assert.Equal("ProductID", salesProductsRel.ToColumn); - Assert.True(salesProductsRel.IsActive); - } - - /// - /// Tests creating a new relationship between tables. - /// LLM use case: "create a relationship between these tables" - /// - [Fact] - public async Task CreateRelationship_ValidTablesAndColumns_CreatesSuccessfully() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - // Delete existing relationship first to allow recreating it - var listResult = await _dataModelCommands.ListRelationships(batch); - if (listResult.Success && listResult.Relationships?.Any(r => - r.FromTable == "SalesTable" && r.ToTable == "CustomersTable" && - r.FromColumn == "CustomerID" && r.ToColumn == "CustomerID") == true) - { - _ = _dataModelCommands.DeleteRelationship(batch, "SalesTable", "CustomerID", "CustomersTable", "CustomerID"); // DeleteRelationship throws on error - } - - // Create relationship - _ = _dataModelCommands.CreateRelationship( - batch, "SalesTable", "CustomerID", "CustomersTable", "CustomerID"); // CreateRelationship throws on error - - // Verify creation - var verifyResult = await _dataModelCommands.ListRelationships(batch); - Assert.Contains(verifyResult.Relationships, r => - r.FromTable == "SalesTable" && r.ToTable == "CustomersTable" && - r.FromColumn == "CustomerID" && r.ToColumn == "CustomerID"); - } - - /// - /// Tests deleting a relationship. - /// LLM use case: "delete this relationship" - /// - [Fact] - public async Task DeleteRelationship_ExistingRelationship_ReturnsSuccess() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - // Delete relationship - _ = _dataModelCommands.DeleteRelationship( - batch, "SalesTable", "CustomerID", "CustomersTable", "CustomerID"); // DeleteRelationship throws on error - - // Verify deletion - var verifyResult = await _dataModelCommands.ListRelationships(batch); - Assert.DoesNotContain(verifyResult.Relationships, r => - r.FromTable == "SalesTable" && r.ToTable == "CustomersTable" && - r.FromColumn == "CustomerID" && r.ToColumn == "CustomerID"); - - // Recreate for other tests (shared file) - _ = _dataModelCommands.CreateRelationship(batch, - "SalesTable", "CustomerID", "CustomersTable", "CustomerID", active: true); // CreateRelationship throws on error - } - - #endregion -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/File/FileCommandsTests.IrmDetection.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/File/FileCommandsTests.IrmDetection.cs deleted file mode 100644 index d3a1c5a9..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/File/FileCommandsTests.IrmDetection.cs +++ /dev/null @@ -1,81 +0,0 @@ -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.File; - -/// -/// Tests for IRM (Azure Information Rights Management) file detection. -/// Feature: Improvement #5 IRM File Auto-Detection and Suggestions -/// -public partial class FileCommandsTests -{ - // === IMPROVEMENT #5: IRM DETECTION TESTS === - - [Fact] - public void Test_NormalFile_NoIrmProtection() - { - // Arrange - Create a normal Excel file without IRM - var testFile = _fixture.CreateTestFile(); - - // Act - var info = _fileCommands.Test(testFile); - - // Assert - Assert.True(info.Exists); - Assert.True(info.IsValid); - Assert.False(info.IsIrmProtected); - Assert.Null(info.Message); - } - - [Fact] - public void Test_IrmProtectedFile_DetectsProtection() - { - // Arrange - Skip if no IRM protected test file available - // For now, simulate or use a real IRM-protected file if available - string? irmTestFile = Environment.GetEnvironmentVariable("TEST_IRM_FILE"); - if (string.IsNullOrEmpty(irmTestFile) || !System.IO.File.Exists(irmTestFile)) - { - // Skip this test - requires actual IRM-protected file - return; - } - - // Act - var info = _fileCommands.Test(irmTestFile); - - // Assert - Assert.True(info.Exists); - Assert.True(info.IsIrmProtected); - // IsValid might be false or true depending on implementation - main thing is IRM detection - } - - [Fact] - public void Test_FileInfo_IncludesIrmStatus() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - // Act - var info = _fileCommands.Test(testFile); - - // Assert - Ensure IRM detection info is available - Assert.NotNull(info); - // The FileValidationInfo should have IsIrmProtected property populated - Assert.False(info.IsIrmProtected); // Normal test file should not be IRM protected - } - - [Fact] - public void Test_RvToolsExportFile_DetectsIrmIfPresent() - { - // Arrange - Test with a typical RVTools export pattern - // RVTools exports from Mercedes/FDC would be IRM-protected - var testFile = _fixture.CreateTestFile(); - - // Act - var info = _fileCommands.Test(testFile); - - // Assert - IRM detection should work for RVTools pattern - // (Our test file is not IRM, but the detection logic should work) - Assert.True(info.Exists); - Assert.False(info.IsIrmProtected); // Unprotected test file - } -} diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/File/FileCommandsTests.TestFile.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/File/FileCommandsTests.TestFile.cs deleted file mode 100644 index 37fff4a3..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/File/FileCommandsTests.TestFile.cs +++ /dev/null @@ -1,68 +0,0 @@ -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.File; - -/// -/// Tests for FileCommands TestFile operation -/// -public partial class FileCommandsTests -{ - [Fact] - public void Test_ExistingValidFile_ReturnsSuccess() - { - // Arrange - Create a valid file - var testFile = _fixture.CreateTestFile(); - - // Act - var info = _fileCommands.Test(testFile); - - // Assert - Assert.True(info.Exists); - Assert.True(info.IsValid); - Assert.Equal(".xlsx", info.Extension); - Assert.True(info.Size > 0); - Assert.Null(info.Message); - } - [Fact] - public void Test_NonExistent_ReturnsFailure() - { - // Arrange - string testFile = Path.Join(_fixture.TempDir, $"NonExistent_{Guid.NewGuid():N}.xlsx"); - - // Act - var info = _fileCommands.Test(testFile); - - // Assert - Assert.False(info.Exists); - Assert.False(info.IsValid); - Assert.NotNull(info.Message); - Assert.Contains("not found", info.Message, StringComparison.OrdinalIgnoreCase); - } - [Theory] - [InlineData("TestFile.xls", ".xls")] - [InlineData("TestFile.csv", ".csv")] - [InlineData("TestFile.txt", ".txt")] - public void Test_InvalidExtension_ReturnsFailure(string fileName, string expectedExt) - { - // Arrange - string testFile = Path.Join(_fixture.TempDir, $"{Guid.NewGuid():N}_{fileName}"); - - // Create file with invalid extension - System.IO.File.WriteAllText(testFile, "test content"); - - // Act - var info = _fileCommands.Test(testFile); - - // Assert - Assert.True(info.Exists); - Assert.False(info.IsValid); - Assert.Equal(expectedExt, info.Extension); - Assert.NotNull(info.Message); - Assert.Contains("Invalid file extension", info.Message, StringComparison.OrdinalIgnoreCase); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/File/FileCommandsTests.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/File/FileCommandsTests.cs deleted file mode 100644 index a0478e6e..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/File/FileCommandsTests.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Sbroenne.ExcelMcp.Core.Commands; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.File; - -/// -/// Integration tests for File Core operations using Excel COM automation. -/// Tests Core layer directly (not through CLI wrapper). -/// Each test uses a unique Excel file for complete test isolation. -/// -/// WHAT LLMs NEED TO KNOW: -/// 1. TestFile returns metadata (Exists, IsValid, Message) without Success flag -/// 2. File creation uses SessionManager.CreateSessionForNewFile (create action) -/// -/// LAYER RESPONSIBILITY: -/// - ✅ Test Excel COM file operations and Result objects -/// - ✅ Test business rules (valid extensions, file metadata) -/// - ❌ DO NOT test CLI argument parsing (CLI's responsibility) -/// - ❌ DO NOT test JSON serialization (MCP Server's responsibility) -/// - ❌ DO NOT test infrastructure (paths, directories, OS validation) -/// -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("Speed", "Medium")] -[Trait("Feature", "Files")] -[Trait("RequiresExcel", "true")] -public partial class FileCommandsTests : IClassFixture -{ - // Performance: use concrete type to satisfy CA1859 (test code, not API surface) - private readonly FileCommands _fileCommands; - private readonly FileTestsFixture _fixture; - - public FileCommandsTests(FileTestsFixture fixture) - { - _fileCommands = new FileCommands(); - _fixture = fixture; - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/NamedRange/NamedRangeCommandsTests.Lifecycle.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/NamedRange/NamedRangeCommandsTests.Lifecycle.cs deleted file mode 100644 index 820139ec..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/NamedRange/NamedRangeCommandsTests.Lifecycle.cs +++ /dev/null @@ -1,76 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.NamedRange; - -/// -/// Tests for Parameter lifecycle operations (list, create, delete) -/// -public partial class NamedRangeCommandsTests -{ - /// - [Fact] - public void List_EmptyWorkbook_ReturnsEmptyList() - { - // Arrange - Use the shared fixture file - // Note: The shared file may have named ranges from other tests, - // so we verify the list operation works rather than asserting empty - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - - // Act - var namedRanges = _parameterCommands.List(batch); - - // Assert - List should return without error - Assert.NotNull(namedRanges); - } - /// - - [Fact] - public void Create_ValidNameAndReference_ReturnsSuccess() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var paramName = NamedRangeTestsFixture.GetUniqueNamedRangeName(); - var cellRef = _fixture.GetUniqueCellReference(); - - // Act - _parameterCommands.Create(batch, paramName, cellRef); - - // Assert - Verify the parameter was actually created by listing parameters - var namedRanges = _parameterCommands.List(batch); - Assert.Contains(namedRanges, p => p.Name == paramName); - } - /// - - [Fact] - public void Delete_ExistingParameter_ReturnsSuccess() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var paramName = NamedRangeTestsFixture.GetUniqueNamedRangeName(); - var cellRef = _fixture.GetUniqueCellReference(); - - // Create parameter first - _parameterCommands.Create(batch, paramName, cellRef); - - // Delete the parameter - _parameterCommands.Delete(batch, paramName); - - // Assert - Verify the parameter was actually deleted by checking it's not in the list - var namedRanges = _parameterCommands.List(batch); - Assert.DoesNotContain(namedRanges, p => p.Name == paramName); - } - /// - - [Fact] - public void List_WithNonExistentFile_ReturnsError() - { - // Act & Assert - Assert.Throws(() => ExcelSession.BeginBatch("nonexistent.xlsx")); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/NamedRange/NamedRangeCommandsTests.Validation.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/NamedRange/NamedRangeCommandsTests.Validation.cs deleted file mode 100644 index 53cd05c5..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/NamedRange/NamedRangeCommandsTests.Validation.cs +++ /dev/null @@ -1,95 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.NamedRange; - -/// -/// Tests for Named Range parameter name validation -/// Validates Excel's 255-character limit for named range names -/// -[Trait("Category", "Integration")] -[Trait("Speed", "Medium")] -[Trait("Layer", "Core")] -[Trait("Feature", "Parameters")] -[Trait("RequiresExcel", "true")] -public partial class NamedRangeCommandsTests -{ - /// - [Fact] - public void Create_EmptyParameterName_ReturnsError() - { - // Arrange & Act & Assert - Empty parameter name should throw ArgumentException - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var exception = Assert.Throws(() => - _parameterCommands.Create(batch, "", "Sheet1!A1")); - - Assert.Contains("cannot be empty", exception.Message, StringComparison.OrdinalIgnoreCase); - } - /// - - [Fact] - public void Create_WhitespaceParameterName_ReturnsError() - { - // Arrange & Act & Assert - Whitespace parameter name should throw ArgumentException - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var exception = Assert.Throws(() => - _parameterCommands.Create(batch, " ", "Sheet1!A1")); - - Assert.Contains("cannot be empty", exception.Message, StringComparison.OrdinalIgnoreCase); - } - /// - - [Fact] - public void Create_ParameterNameExactly255Characters_ReturnsSuccess() - { - // Arrange - Create name with exactly 255 characters (Excel's limit) - // Named ranges must start with letter or underscore, so use "NR_" prefix - var uniquePrefix = "NR_" + Guid.NewGuid().ToString("N")[..5] + "_"; - var paramName = uniquePrefix + new string('A', 255 - uniquePrefix.Length); - - // Act - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - _parameterCommands.Create(batch, paramName, _fixture.GetUniqueCellReference()); - - // Assert - Verify the parameter was actually created - var namedRanges = _parameterCommands.List(batch); - Assert.Contains(namedRanges, p => p.Name == paramName); - } - /// - - [Fact] - public void Create_ParameterName256Characters_ReturnsError() - { - // Arrange - Create name with 256 characters (exceeds Excel's limit) - var paramName = new string('B', 256); - - // Act & Assert - 256-character name should throw ArgumentException - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var exception = Assert.Throws(() => - _parameterCommands.Create(batch, paramName, "Sheet1!A1")); - - Assert.Contains("255-character limit", exception.Message); - Assert.Contains("256", exception.Message); // Should show actual length - } - /// - - [Fact] - public void Update_ParameterNameExceeds255Characters_ReturnsError() - { - // Arrange - var longParamName = new string('C', 300); - - // Act & Assert - 300-character name should throw ArgumentException - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var exception = Assert.Throws(() => - _parameterCommands.Update(batch, longParamName, "Sheet1!B2")); - - Assert.Contains("255-character limit", exception.Message); - Assert.Contains("300", exception.Message); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/NamedRange/NamedRangeCommandsTests.Values.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/NamedRange/NamedRangeCommandsTests.Values.cs deleted file mode 100644 index ab7e3e5d..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/NamedRange/NamedRangeCommandsTests.Values.cs +++ /dev/null @@ -1,67 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.NamedRange; - -/// -/// Tests for Parameter value operations (get, set) -/// -public partial class NamedRangeCommandsTests -{ - /// - [Fact] - public void Set_ExistingParameter_UpdatesValue() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var paramName = NamedRangeTestsFixture.GetUniqueNamedRangeName(); - var cellRef = _fixture.GetUniqueCellReference(); - - // Create parameter first - _parameterCommands.Create(batch, paramName, cellRef); - - // Set the parameter value - _parameterCommands.Write(batch, paramName, "TestValue"); - - // Assert - Verify the parameter value was actually set by reading it back - var namedRangeValue = _parameterCommands.Read(batch, paramName); - Assert.Equal("TestValue", namedRangeValue.Value?.ToString()); - } - /// - - [Fact] - public void Get_ExistingParameter_ReturnsValue() - { - // Arrange - string testValue = "Integration Test Value"; - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var paramName = NamedRangeTestsFixture.GetUniqueNamedRangeName(); - var cellRef = _fixture.GetUniqueCellReference(); - - // Create and set parameter value - _parameterCommands.Create(batch, paramName, cellRef); - _parameterCommands.Write(batch, paramName, testValue); - - // Get the parameter value - var namedRangeValue = _parameterCommands.Read(batch, paramName); - - // Assert - Assert.Equal(testValue, namedRangeValue.Value?.ToString()); - } - /// - - [Fact] - public void Get_WithNonExistentParameter_ThrowsException() - { - // Arrange & Act & Assert - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var exception = Assert.Throws( - () => _parameterCommands.Read(batch, $"NonExistent_{Guid.NewGuid():N}")); - Assert.Contains("not found", exception.Message, StringComparison.OrdinalIgnoreCase); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/NamedRange/NamedRangeCommandsTests.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/NamedRange/NamedRangeCommandsTests.cs deleted file mode 100644 index ad685a25..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/NamedRange/NamedRangeCommandsTests.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Sbroenne.ExcelMcp.Core.Commands; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.NamedRange; - -/// -/// Integration tests for Parameter Core operations using Excel COM automation. -/// Tests Core layer directly (not through CLI wrapper). -/// Uses shared fixture for test isolation - each test uses unique named range names. -/// -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("Speed", "Fast")] -[Trait("Feature", "Parameters")] -[Trait("RequiresExcel", "true")] -public partial class NamedRangeCommandsTests : IClassFixture -{ - private readonly NamedRangeCommands _parameterCommands; - private readonly NamedRangeTestsFixture _fixture; - - /// - /// Initializes a new instance of the class. - /// - public NamedRangeCommandsTests(NamedRangeTestsFixture fixture) - { - _parameterCommands = new NamedRangeCommands(); - _fixture = fixture; - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.BugRegression.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.BugRegression.cs deleted file mode 100644 index 69770a2d..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.BugRegression.cs +++ /dev/null @@ -1,196 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands.PivotTable; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; -using Xunit.Abstractions; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.PivotTable; - -/// -/// Regression tests for PivotTable creation with sheet names containing spaces. -/// Same root cause as Bug 1 from Bug Report 2026-02-23: sourceDataRef is constructed -/// as $"{sourceSheet}!{sourceRange}" in PivotTableCommands.Create.cs line 45 without -/// quoting the sheet name. Excel COM requires single quotes around sheet names with -/// spaces: "'Sheet Name'!A1:D6". -/// -public partial class PivotTableCommandsTests -{ - /// - /// Regression: CreateFromRange fails when source sheet name contains spaces. - /// The source data reference "$sourceSheet!$sourceRange" is not quoted. - /// Expected: PivotCache.Create succeeds with "'Sales Data'!A1:D6". - /// Actual: COM error because "Sales Data!A1:D6" is invalid. - /// - [Fact] - public void CreateFromRange_SourceSheetWithSpaces_CreatesPivotTable() - { - // Arrange — create file with data on a sheet whose name has spaces - var testFile = CreateTestFileWithData_SheetWithSpaces( - nameof(CreateFromRange_SourceSheetWithSpaces_CreatesPivotTable)); - - // Act - using var batch = ExcelSession.BeginBatch(testFile); - var result = _pivotCommands.CreateFromRange( - batch, - "Sales Data", "A1:D6", // source sheet with space in name - "Sales Data", "F1", // destination on same sheet - "SpacePivot"); - - // Assert - Assert.True(result.Success, $"Expected success but got error: {result.ErrorMessage}"); - Assert.Equal("SpacePivot", result.PivotTableName); - Assert.Equal(4, result.AvailableFields.Count); - } - - /// - /// Regression: CreateFromRange fails when destination sheet name contains spaces. - /// While the current code may handle the destination correctly (it uses - /// Worksheets[name] not a string reference), this test validates the full path. - /// - [Fact] - public void CreateFromRange_DestinationSheetWithSpaces_CreatesPivotTable() - { - // Arrange - var testFile = CreateTestFileWithData_SheetWithSpaces( - nameof(CreateFromRange_DestinationSheetWithSpaces_CreatesPivotTable)); - - // Create a second sheet for the PivotTable destination - using (var setupBatch = ExcelSession.BeginBatch(testFile)) - { - setupBatch.Execute((ctx, ct) => - { - dynamic? sheets = null; - dynamic? newSheet = null; - try - { - sheets = ctx.Book.Worksheets; - newSheet = sheets.Add(); - newSheet.Name = "Pivot Output"; - return 0; - } - finally - { - ComUtilities.Release(ref newSheet); - ComUtilities.Release(ref sheets); - } - }); - setupBatch.Save(); - } // setupBatch disposed — file lock released - - // Act - using var batch = ExcelSession.BeginBatch(testFile); - var result = _pivotCommands.CreateFromRange( - batch, - "Sales Data", "A1:D6", // source sheet with space - "Pivot Output", "A1", // destination sheet with space - "CrossSheetPivot"); - - // Assert - Assert.True(result.Success, $"Expected success but got error: {result.ErrorMessage}"); - Assert.Equal("CrossSheetPivot", result.PivotTableName); - } - - /// - /// Regression: CreateFromRange fails when source sheet name contains special characters. - /// Excel requires single quotes for sheet names with spaces, hyphens, or other specials. - /// - [Fact] - public void CreateFromRange_SourceSheetWithHyphen_CreatesPivotTable() - { - // Arrange — sheet name with hyphen (also requires quoting) - var testFile = _fixture.CreateTestFile( - nameof(CreateFromRange_SourceSheetWithHyphen_CreatesPivotTable)); - - using (var setupBatch = ExcelSession.BeginBatch(testFile)) - { - setupBatch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - sheet.Name = "Q1-Sales"; - - sheet.Range["A1"].Value2 = "Region"; - sheet.Range["B1"].Value2 = "Product"; - sheet.Range["C1"].Value2 = "Sales"; - sheet.Range["D1"].Value2 = "Date"; - - sheet.Range["A2"].Value2 = "North"; - sheet.Range["B2"].Value2 = "Widget"; - sheet.Range["C2"].Value2 = 100; - sheet.Range["D2"].Value2 = new DateTime(2025, 1, 15); - - sheet.Range["A3"].Value2 = "South"; - sheet.Range["B3"].Value2 = "Gadget"; - sheet.Range["C3"].Value2 = 200; - sheet.Range["D3"].Value2 = new DateTime(2025, 2, 10); - - return 0; - }); - setupBatch.Save(); - } // setupBatch disposed — file lock released - - // Act - using var batch = ExcelSession.BeginBatch(testFile); - var result = _pivotCommands.CreateFromRange( - batch, - "Q1-Sales", "A1:D3", - "Q1-Sales", "F1", - "HyphenPivot"); - - // Assert - Assert.True(result.Success, $"Expected success but got error: {result.ErrorMessage}"); - Assert.Equal("HyphenPivot", result.PivotTableName); - } - - /// - /// Helper: Creates a test file with sales data on a sheet named "Sales Data" (with space). - /// - private string CreateTestFileWithData_SheetWithSpaces(string testName) - { - var testFile = _fixture.CreateTestFile(testName); - - using var batch = ExcelSession.BeginBatch(testFile); - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - sheet.Name = "Sales Data"; // Space in name — triggers the bug - - sheet.Range["A1"].Value2 = "Region"; - sheet.Range["B1"].Value2 = "Product"; - sheet.Range["C1"].Value2 = "Sales"; - sheet.Range["D1"].Value2 = "Date"; - - sheet.Range["A2"].Value2 = "North"; - sheet.Range["B2"].Value2 = "Widget"; - sheet.Range["C2"].Value2 = 100; - sheet.Range["D2"].Value2 = new DateTime(2025, 1, 15); - - sheet.Range["A3"].Value2 = "North"; - sheet.Range["B3"].Value2 = "Widget"; - sheet.Range["C3"].Value2 = 150; - sheet.Range["D3"].Value2 = new DateTime(2025, 1, 20); - - sheet.Range["A4"].Value2 = "South"; - sheet.Range["B4"].Value2 = "Gadget"; - sheet.Range["C4"].Value2 = 200; - sheet.Range["D4"].Value2 = new DateTime(2025, 2, 10); - - sheet.Range["A5"].Value2 = "North"; - sheet.Range["B5"].Value2 = "Gadget"; - sheet.Range["C5"].Value2 = 75; - sheet.Range["D5"].Value2 = new DateTime(2025, 2, 15); - - sheet.Range["A6"].Value2 = "South"; - sheet.Range["B6"].Value2 = "Widget"; - sheet.Range["C6"].Value2 = 125; - sheet.Range["D6"].Value2 = new DateTime(2025, 3, 5); - - sheet.Range["D2:D6"].NumberFormat = "m/d/yyyy"; - - return 0; - }); - - batch.Save(); - return testFile; - } -} diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.CalculatedFields.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.CalculatedFields.cs deleted file mode 100644 index 84fd0e13..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.CalculatedFields.cs +++ /dev/null @@ -1,267 +0,0 @@ -using Microsoft.Extensions.Logging; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.PivotTable; - -/// -/// Tests for PivotTable calculated field operations. -/// Calculated fields create custom fields with formulas for analysis. -/// Regular PivotTables: Full support via CalculatedFields.Add() API. -/// OLAP PivotTables: NOT supported (use DAX measures instead). -/// -public partial class PivotTableCommandsTests -{ - [Fact] - [Trait("Speed", "Medium")] - public void CreateCalculatedField_MultiplicationFormula_CreatesField() - { - // Arrange - Test data has: Region, Product, Sales, Date - var testFile = CreateTestFileWithData(nameof(CreateCalculatedField_MultiplicationFormula_CreatesField)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - // Create PivotTable - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F2", "SalesPivot"); - Assert.True(createResult.Success, $"CreateFromRange failed: {createResult.ErrorMessage}"); - - // Add fields - var rowResult = _pivotCommands.AddRowField(batch, "SalesPivot", "Product"); - Assert.True(rowResult.Success, $"AddRowField failed: {rowResult.ErrorMessage}"); - - var valueResult = _pivotCommands.AddValueField(batch, "SalesPivot", "Sales"); - Assert.True(valueResult.Success, $"AddValueField failed: {valueResult.ErrorMessage}"); - - // Act - Create calculated field (Sales * 2) - var result = _pivotCommands.CreateCalculatedField(batch, "SalesPivot", "DoubleSales", "=Sales*2"); - - // Assert - Assert.True(result.Success, $"CreateCalculatedField failed: {result.ErrorMessage}"); - Assert.Equal("DoubleSales", result.FieldName); - Assert.Equal("=Sales*2", result.Formula); - Assert.NotNull(result.WorkflowHint); - Assert.Contains("Add to Values area", result.WorkflowHint); - - // Verify field exists - var listResult = _pivotCommands.ListFields(batch, "SalesPivot"); - Assert.True(listResult.Success, $"ListFields failed: {listResult.ErrorMessage}"); - Assert.Contains(listResult.Fields, f => f.Name == "DoubleSales"); - } - - [Fact] - [Trait("Speed", "Medium")] - public void CreateCalculatedField_SubtractionFormula_CreatesField() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(CreateCalculatedField_SubtractionFormula_CreatesField)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F2", "SalesPivot"); - Assert.True(createResult.Success); - - var rowResult = _pivotCommands.AddRowField(batch, "SalesPivot", "Region"); - Assert.True(rowResult.Success); - - var valueResult = _pivotCommands.AddValueField(batch, "SalesPivot", "Sales"); - Assert.True(valueResult.Success); - - // Act - Subtraction formula (Sales - 100) - var result = _pivotCommands.CreateCalculatedField(batch, "SalesPivot", "AfterDiscount", "=Sales-100"); - - // Assert - Assert.True(result.Success, $"CreateCalculatedField failed: {result.ErrorMessage}"); - Assert.Equal("AfterDiscount", result.FieldName); - Assert.Equal("=Sales-100", result.Formula); - - var listResult = _pivotCommands.ListFields(batch, "SalesPivot"); - Assert.True(listResult.Success); - Assert.Contains(listResult.Fields, f => f.Name == "AfterDiscount"); - } - - [Fact] - [Trait("Speed", "Medium")] - public void CreateCalculatedField_ComplexFormula_CreatesField() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(CreateCalculatedField_ComplexFormula_CreatesField)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F2", "SalesPivot"); - Assert.True(createResult.Success); - - var rowResult = _pivotCommands.AddRowField(batch, "SalesPivot", "Product"); - Assert.True(rowResult.Success); - - var valueResult = _pivotCommands.AddValueField(batch, "SalesPivot", "Sales"); - Assert.True(valueResult.Success); - - // Act - Complex formula with parentheses: (Sales - 50) / Sales - var result = _pivotCommands.CreateCalculatedField(batch, "SalesPivot", "ProfitMargin", "=(Sales-50)/Sales"); - - // Assert - Assert.True(result.Success, $"CreateCalculatedField failed: {result.ErrorMessage}"); - Assert.Equal("ProfitMargin", result.FieldName); - Assert.Equal("=(Sales-50)/Sales", result.Formula); - - var listResult = _pivotCommands.ListFields(batch, "SalesPivot"); - Assert.True(listResult.Success); - Assert.Contains(listResult.Fields, f => f.Name == "ProfitMargin"); - } - - [Fact] - [Trait("Speed", "Medium")] - public void CreateCalculatedField_AdditionFormula_CreatesField() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(CreateCalculatedField_AdditionFormula_CreatesField)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F2", "SalesPivot"); - Assert.True(createResult.Success); - - var rowResult = _pivotCommands.AddRowField(batch, "SalesPivot", "Region"); - Assert.True(rowResult.Success); - - var valueResult = _pivotCommands.AddValueField(batch, "SalesPivot", "Sales"); - Assert.True(valueResult.Success); - - // Act - Addition formula (Sales + 50 as bonus) - var result = _pivotCommands.CreateCalculatedField(batch, "SalesPivot", "WithBonus", "=Sales+50"); - - // Assert - Assert.True(result.Success, $"CreateCalculatedField failed: {result.ErrorMessage}"); - Assert.Equal("WithBonus", result.FieldName); - Assert.Equal("=Sales+50", result.Formula); - - var listResult = _pivotCommands.ListFields(batch, "SalesPivot"); - Assert.True(listResult.Success); - Assert.Contains(listResult.Fields, f => f.Name == "WithBonus"); - } - - [Fact] - [Trait("Speed", "Medium")] - public void ListCalculatedFields_NoCalculatedFields_ReturnsEmptyList() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(ListCalculatedFields_NoCalculatedFields_ReturnsEmptyList)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F2", "SalesPivot"); - Assert.True(createResult.Success); - - // Act - List calculated fields (should be empty) - var result = _pivotCommands.ListCalculatedFields(batch, "SalesPivot"); - - // Assert - Assert.True(result.Success, $"ListCalculatedFields failed: {result.ErrorMessage}"); - Assert.Empty(result.CalculatedFields); - } - - [Fact] - [Trait("Speed", "Medium")] - public void ListCalculatedFields_AfterCreate_ReturnsField() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(ListCalculatedFields_AfterCreate_ReturnsField)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - var createPivotResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F2", "SalesPivot"); - Assert.True(createPivotResult.Success); - - // Add a row field and value to make valid PivotTable - _pivotCommands.AddRowField(batch, "SalesPivot", "Region"); - _pivotCommands.AddValueField(batch, "SalesPivot", "Sales"); - - // Create a calculated field - var createResult = _pivotCommands.CreateCalculatedField(batch, "SalesPivot", "DoubleSales", "=Sales*2"); - Assert.True(createResult.Success, $"CreateCalculatedField failed: {createResult.ErrorMessage}"); - - // Act - List calculated fields - var result = _pivotCommands.ListCalculatedFields(batch, "SalesPivot"); - - // Assert - Assert.True(result.Success, $"ListCalculatedFields failed: {result.ErrorMessage}"); - Assert.Single(result.CalculatedFields); - Assert.Equal("DoubleSales", result.CalculatedFields[0].Name); - Assert.Contains("Sales*2", result.CalculatedFields[0].Formula); - } - - [Fact] - [Trait("Speed", "Medium")] - public void DeleteCalculatedField_ExistingField_RemovesField() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(DeleteCalculatedField_ExistingField_RemovesField)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - var createPivotResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F2", "SalesPivot"); - Assert.True(createPivotResult.Success); - - _pivotCommands.AddRowField(batch, "SalesPivot", "Region"); - _pivotCommands.AddValueField(batch, "SalesPivot", "Sales"); - - // Create a calculated field - var createResult = _pivotCommands.CreateCalculatedField(batch, "SalesPivot", "TestCalcField", "=Sales*3"); - Assert.True(createResult.Success); - - // Verify it exists - var listBefore = _pivotCommands.ListCalculatedFields(batch, "SalesPivot"); - Assert.Contains(listBefore.CalculatedFields, f => f.Name == "TestCalcField"); - - // Act - Delete the calculated field - var deleteResult = _pivotCommands.DeleteCalculatedField(batch, "SalesPivot", "TestCalcField"); - - // Assert - Assert.True(deleteResult.Success, $"DeleteCalculatedField failed: {deleteResult.ErrorMessage}"); - - // Verify it's gone - var listAfter = _pivotCommands.ListCalculatedFields(batch, "SalesPivot"); - Assert.DoesNotContain(listAfter.CalculatedFields, f => f.Name == "TestCalcField"); - } - - [Fact] - [Trait("Speed", "Medium")] - public void DeleteCalculatedField_NonExistentField_ReturnsError() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(DeleteCalculatedField_NonExistentField_ReturnsError)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - var createPivotResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F2", "SalesPivot"); - Assert.True(createPivotResult.Success); - - // Act - Try to delete non-existent field - var result = _pivotCommands.DeleteCalculatedField(batch, "SalesPivot", "NonExistentField"); - - // Assert - Assert.False(result.Success); - Assert.Contains("not found", result.ErrorMessage); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.CalculatedFieldsDataModel.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.CalculatedFieldsDataModel.cs deleted file mode 100644 index ceaee47e..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.CalculatedFieldsDataModel.cs +++ /dev/null @@ -1,71 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands.PivotTable; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.PivotTable; - -/// -/// Tests for calculated fields with Data Model / OLAP PivotTables. -/// OLAP PivotTables do NOT support CalculatedFields (Excel COM limitation). -/// For OLAP, use DAX measures via datamodel tool instead. -/// -[Collection("DataModel")] -[Trait("Category", "Integration")] -[Trait("Speed", "Slow")] -[Trait("Layer", "Core")] -[Trait("Feature", "PivotTables")] -[Trait("RequiresExcel", "true")] -public class PivotTableCalculatedFieldsDataModelTests -{ - private readonly PivotTableCommands _pivotCommands; - private readonly string _dataModelFile; - private readonly DataModelPivotTableCreationResult _creationResult; - - public PivotTableCalculatedFieldsDataModelTests(DataModelPivotTableFixture fixture) - { - _pivotCommands = new PivotTableCommands(); - _dataModelFile = fixture.TestFilePath; - _creationResult = fixture.CreationResult; - } - - [Fact] - public void CreateCalculatedField_OlapPivotTable_ReturnsNotSupported() - { - // Arrange - Verify Data Model exists - Assert.True(_creationResult.Success, $"Data Model creation failed: {_creationResult.ErrorMessage}"); - - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - // Create OLAP PivotTable from Data Model (SalesTable has: SalesID, Date, CustomerID, ProductID, Amount, Quantity) - // Use existing "SalesData" sheet from fixture - var createResult = _pivotCommands.CreateFromDataModel( - batch, "SalesTable", "SalesData", "K1", "OlapSalesCalcTest"); - Assert.True(createResult.Success, $"Failed to create OLAP PivotTable: {createResult.ErrorMessage}"); - - // Add fields to PivotTable - use exact CubeField names (LLM discovers via ListFields) - var rowResult = _pivotCommands.AddRowField(batch, "OlapSalesCalcTest", "[SalesTable].[ProductID]"); - Assert.True(rowResult.Success, $"AddRowField failed: {rowResult.ErrorMessage}"); - - var valueResult = _pivotCommands.AddValueField(batch, "OlapSalesCalcTest", "[SalesTable].[Amount]"); - Assert.True(valueResult.Success, $"AddValueField failed: {valueResult.ErrorMessage}"); - - // Act - Attempt to create calculated field on OLAP PivotTable - var result = _pivotCommands.CreateCalculatedField(batch, "OlapSalesCalcTest", "TestField", "=Amount*2"); - - // Assert - Should fail with NotSupported message - Assert.False(result.Success, "CreateCalculatedField should fail for OLAP PivotTables"); - Assert.NotNull(result.ErrorMessage); - Assert.Contains("not supported", result.ErrorMessage.ToLowerInvariant()); - Assert.Contains("OLAP", result.ErrorMessage); - - // Verify workflow hint points to DAX measures - Assert.NotNull(result.WorkflowHint); - Assert.Contains("datamodel", result.WorkflowHint); - Assert.Contains("DAX", result.WorkflowHint); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.CalculatedMembers.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.CalculatedMembers.cs deleted file mode 100644 index 0a2d0fcf..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.CalculatedMembers.cs +++ /dev/null @@ -1,237 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands.PivotTable; -using Sbroenne.ExcelMcp.Core.Models; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.PivotTable; - -/// -/// Integration tests for PivotTable calculated members operations. -/// Calculated members are OLAP-only features that work with Data Model PivotTables. -/// -[Collection("DataModel")] -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("RequiresExcel", "true")] -[Trait("Feature", "PivotTables")] -[Trait("Speed", "Slow")] -public class PivotTableCalculatedMembersTests -{ - private readonly PivotTableCommands _pivotCommands; - private readonly string _dataModelFile; - private readonly DataModelPivotTableCreationResult _creationResult; - - /// - /// Initializes a new instance of the class. - /// - public PivotTableCalculatedMembersTests(DataModelPivotTableFixture fixture) - { - _pivotCommands = new PivotTableCommands(); - _dataModelFile = fixture.TestFilePath; - _creationResult = fixture.CreationResult; - } - - /// - /// Tests listing calculated members on an OLAP PivotTable without any calculated members. - /// - [Fact] - public void ListCalculatedMembers_OlapPivotTableNoMembers_ReturnsEmptyList() - { - // Arrange - Assert.True(_creationResult.Success, "Data Model fixture must be created successfully"); - - // Act - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = _pivotCommands.ListCalculatedMembers(batch, "DataModelPivot"); - - // Assert - Assert.True(result.Success, $"Expected success but got error: {result.ErrorMessage}"); - Assert.NotNull(result.CalculatedMembers); - // May or may not have calculated members depending on fixture state - } - - /// - /// Tests creating a calculated member (measure type) on an OLAP PivotTable. - /// - [Fact] - public void CreateCalculatedMember_ValidMeasure_ReturnsSuccess() - { - // Arrange - Assert.True(_creationResult.Success, "Data Model fixture must be created successfully"); - - // Act - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - // Create a calculated measure - note: for Power Pivot Data Model, use DAX-style references - // The formula depends on the cube structure from the Data Model - var result = _pivotCommands.CreateCalculatedMember( - batch, - "DataModelPivot", - "[Measures].[TestMeasure]", - "[Measures].[TotalRevenue] * 1.1", // Reference existing measure - CalculatedMemberType.Measure, - 0, - null, - null); - - // Assert - Calculated members may fail on some Data Model configurations - // Just verify the call completes and returns a valid result - if (result.Success) - { - Assert.Contains("TestMeasure", result.Name); - Assert.NotNull(result.WorkflowHint); - - // Verify the member was created by listing - var listResult = _pivotCommands.ListCalculatedMembers(batch, "DataModelPivot"); - Assert.True(listResult.Success); - Assert.Contains(listResult.CalculatedMembers, m => m.Name.Contains("TestMeasure")); - } - else - { - // Some formula syntax may not work - this is acceptable - // The important thing is we got a valid error message - Assert.NotNull(result.ErrorMessage); - } - } - - /// - /// Tests creating and then deleting a calculated member. - /// - [Fact] - public void DeleteCalculatedMember_AfterCreate_RemovesMember() - { - // Arrange - Assert.True(_creationResult.Success, "Data Model fixture must be created successfully"); - - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - // First create a member to delete - use a simple formula that's more likely to work - var createResult = _pivotCommands.CreateCalculatedMember( - batch, - "DataModelPivot", - "[Measures].[ToBeDeleted]", - "1 + 1", // Simple formula that should always work - CalculatedMemberType.Measure); - - // If create fails, skip the delete test - formula syntax may not be compatible - if (!createResult.Success) - { - // Just verify the create returned a proper error - Assert.NotNull(createResult.ErrorMessage); - return; - } - - // Verify it exists - var listBefore = _pivotCommands.ListCalculatedMembers(batch, "DataModelPivot"); - Assert.Contains(listBefore.CalculatedMembers, m => m.Name.Contains("ToBeDeleted")); - - // Act - Delete the member - var deleteResult = _pivotCommands.DeleteCalculatedMember(batch, "DataModelPivot", "[Measures].[ToBeDeleted]"); - - // Assert - Assert.True(deleteResult.Success, $"Delete failed: {deleteResult.ErrorMessage}"); - - // Verify it's gone - var listAfter = _pivotCommands.ListCalculatedMembers(batch, "DataModelPivot"); - Assert.DoesNotContain(listAfter.CalculatedMembers, m => m.Name.Contains("ToBeDeleted")); - } - - /// - /// Tests that calculated members fail on non-OLAP PivotTables with a helpful error. - /// - [Fact] - public void ListCalculatedMembers_NonOlapPivotTable_ReturnsError() - { - // Arrange - Assert.True(_creationResult.Success, "Data Model fixture must be created successfully"); - - // Act - Try to list calculated members on a range-based (non-OLAP) PivotTable - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = _pivotCommands.ListCalculatedMembers(batch, "SalesByRegion"); - - // Assert - Assert.False(result.Success); - Assert.Contains("not an OLAP PivotTable", result.ErrorMessage); - Assert.Contains("create-calculated-field", result.ErrorMessage); // Suggests alternative - } - - /// - /// Tests that creating calculated members fails on non-OLAP PivotTables. - /// - [Fact] - public void CreateCalculatedMember_NonOlapPivotTable_ReturnsError() - { - // Arrange - Assert.True(_creationResult.Success, "Data Model fixture must be created successfully"); - - // Act - Try to create calculated member on a table-based (non-OLAP) PivotTable - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = _pivotCommands.CreateCalculatedMember( - batch, - "RegionalSummary", - "[Measures].[ShouldFail]", - "[Measures].[Something]", - CalculatedMemberType.Measure); - - // Assert - Assert.False(result.Success); - Assert.Contains("not an OLAP PivotTable", result.ErrorMessage); - } - - /// - /// Tests deleting a non-existent calculated member returns appropriate error. - /// - [Fact] - public void DeleteCalculatedMember_NonExistentMember_ReturnsError() - { - // Arrange - Assert.True(_creationResult.Success, "Data Model fixture must be created successfully"); - - // Act - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = _pivotCommands.DeleteCalculatedMember(batch, "DataModelPivot", "[Measures].[NonExistent]"); - - // Assert - Assert.False(result.Success); - Assert.Contains("not found", result.ErrorMessage); - Assert.Contains("list-calculated-members", result.ErrorMessage); - } - - /// - /// Tests creating a calculated set member type. - /// - [Fact] - public void CreateCalculatedMember_SetType_ReturnsSuccess() - { - // Arrange - Assert.True(_creationResult.Success, "Data Model fixture must be created successfully"); - - // Act - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - // Create a calculated set using MDX syntax - var result = _pivotCommands.CreateCalculatedMember( - batch, - "DataModelPivot", - "[RegionalSalesTable].[Region].[TopRegions]", - "{[RegionalSalesTable].[Region].&[North], [RegionalSalesTable].[Region].&[South]}", - CalculatedMemberType.Set); - - // Assert - Sets may not be supported for all Data Model configurations - // Just verify the call completes without throwing - if (result.Success) - { - Assert.Equal(CalculatedMemberType.Set, result.Type); - } - else - { - // Some Excel configurations may not support sets - this is acceptable - Assert.NotNull(result.ErrorMessage); - } - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.Creation.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.Creation.cs deleted file mode 100644 index 16162cb7..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.Creation.cs +++ /dev/null @@ -1,131 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands.Table; -using Sbroenne.ExcelMcp.Core.Models; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.PivotTable; - -/// -/// Tests for PivotTable creation operations -/// -public partial class PivotTableCommandsTests -{ - /// - [Fact] - public void CreateFromRange_PopulatedRangeWithHeaders_CreatesCorrectPivotStructure() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(CreateFromRange_PopulatedRangeWithHeaders_CreatesCorrectPivotStructure)); - - // Act - using var batch = ExcelSession.BeginBatch(testFile); - var result = _pivotCommands.CreateFromRange( - batch, - "SalesData", "A1:D6", - "SalesData", "F1", - "TestPivot"); - - // Assert - Assert.True(result.Success, $"Expected success but got error: {result.ErrorMessage}"); - Assert.Equal("TestPivot", result.PivotTableName); - Assert.Equal("SalesData", result.SheetName); - Assert.Equal(4, result.AvailableFields.Count); - } - /// - - [Fact] - public void CreateFromTable_WithValidTable_CreatesCorrectPivotStructure() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(CreateFromTable_WithValidTable_CreatesCorrectPivotStructure)); - - // Act - Use single batch for table creation and pivot creation - using var batch = ExcelSession.BeginBatch(testFile); - - // Create table first - var tableCommands = new TableCommands(); - tableCommands.Create(batch, "SalesData", "SalesTable", "A1:D6", true, TableStylePresets.Medium2); // Create throws on error - - // Create pivot from table - var result = _pivotCommands.CreateFromTable( - batch, - "SalesTable", - "SalesData", "F1", - "TablePivot"); - - // Assert - Assert.True(result.Success, $"Expected success but got error: {result.ErrorMessage}"); - Assert.Equal("TablePivot", result.PivotTableName); - Assert.Equal("SalesData", result.SheetName); - Assert.Equal(4, result.AvailableFields.Count); - } - /// - - [Fact] - public void CreateFromDataModel_NoDataModel_ReturnsError() - { - // Arrange - Use regular file without Data Model - var testFile = CreateTestFileWithData(nameof(CreateFromDataModel_NoDataModel_ReturnsError)); - - // Act & Assert - expects exception when Data Model is empty - using var batch = ExcelSession.BeginBatch(testFile); - var ex = Assert.Throws(() => _pivotCommands.CreateFromDataModel( - batch, - "AnyTable", - "SalesData", - "F1", - "FailedPivot")); - Assert.Contains("Data Model does not contain any tables", ex.Message); - } - /// - - [Fact] - public void AddRowField_WithValidField_AddsFieldToRows() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(AddRowField_WithValidField_AddsFieldToRows)); - - // Act - Use single batch for create and add field - using var batch = ExcelSession.BeginBatch(testFile); - - // Create pivot - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F1", "TestPivot"); - Assert.True(createResult.Success); - - // Add row field - var result = _pivotCommands.AddRowField(batch, "TestPivot", "Region"); - - // Assert - Assert.True(result.Success, $"Expected success but got error: {result.ErrorMessage}"); - Assert.Equal("Region", result.FieldName); - } - /// - - [Fact] - public void ListFields_AfterCreate_ReturnsAvailableFields() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(ListFields_AfterCreate_ReturnsAvailableFields)); - - // Act - Use single batch for create and list fields - using var batch = ExcelSession.BeginBatch(testFile); - - // Create pivot - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F1", "TestPivot"); - Assert.True(createResult.Success); - - // List fields - var result = _pivotCommands.ListFields(batch, "TestPivot"); - - // Assert - Assert.True(result.Success, $"Expected success but got error: {result.ErrorMessage}"); - Assert.NotNull(result.Fields); - Assert.True(result.Fields.Count >= 4); // Region, Product, Sales, Date - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.DataModel.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.DataModel.cs deleted file mode 100644 index 85f05669..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.DataModel.cs +++ /dev/null @@ -1,120 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands.PivotTable; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.PivotTable; - -/// -/// Integration tests for PivotTable creation from Power Pivot Data Model tables. -/// Uses DataModelPivotTableFixture which creates ONE comprehensive Data Model + PivotTable workbook (shared via collection fixture). -/// -[Collection("DataModel")] -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("RequiresExcel", "true")] -[Trait("Feature", "DataModel")] -[Trait("Feature", "PivotTables")] -[Trait("Speed", "Slow")] -public class PivotTableDataModelTests -{ - private readonly PivotTableCommands _pivotCommands; - private readonly string _dataModelFile; - private readonly DataModelPivotTableCreationResult _creationResult; - - /// - /// Initializes a new instance of the class. - /// - public PivotTableDataModelTests(DataModelPivotTableFixture fixture) - { - _pivotCommands = new PivotTableCommands(); - _dataModelFile = fixture.TestFilePath; - _creationResult = fixture.CreationResult; - } - - /// - /// Tests creating PivotTable from Data Model table. - /// - [Fact] - public async Task CreateFromDataModel_WithValidTable_CreatesCorrectPivotStructure() - { - // Arrange - Use shared Data Model fixture - Assert.True(_creationResult.Success, "Data Model fixture must be created successfully"); - - // Act - Create PivotTable from Data Model table - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = await _pivotCommands.CreateFromDataModel( - batch, - "SalesTable", // Data Model table name from fixture - "SalesData", // Destination sheet (exists in fixture) - "H1", // Destination cell - "SalesDataModelPivot"); - - // Assert - Assert.True(result.Success, $"Expected success but got error: {result.ErrorMessage}"); - Assert.Equal("SalesDataModelPivot", result.PivotTableName); - Assert.Equal("SalesData", result.SheetName); - Assert.NotEmpty(result.Range); - Assert.Contains("ThisWorkbookDataModel", result.SourceData); - Assert.True(result.SourceRowCount > 0, "Should have rows in source Data Model table"); - Assert.NotEmpty(result.AvailableFields); - - // Verify expected fields from SalesTable in Data Model - Assert.Contains("SalesID", result.AvailableFields); - Assert.Contains("CustomerID", result.AvailableFields); - Assert.Contains("Amount", result.AvailableFields); - } - - /// - /// Tests error handling for non-existent Data Model table. - /// - [Fact] - public async Task CreateFromDataModel_NonExistentTable_ReturnsError() - { - // Arrange - Assert.True(_creationResult.Success, "Data Model fixture must be created successfully"); - - // Act & Assert - Try to create PivotTable from non-existent table (should throw) - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var exception = await Assert.ThrowsAsync(async () => - await _pivotCommands.CreateFromDataModel( - batch, - "NonExistentTable", - "SalesData", - "H1", - "FailedPivot")); - - Assert.Contains("not found in Data Model", exception.Message); - } - - /// - /// Tests that all fields from Data Model table are discovered. - /// - [Fact] - public async Task CreateFromDataModel_MultipleFieldsAvailable_ReturnsAllColumns() - { - // Arrange - Assert.True(_creationResult.Success, "Data Model fixture must be created successfully"); - - // Act - Create PivotTable and verify all fields are discovered - using var batch = ExcelSession.BeginBatch(_dataModelFile); - var result = await _pivotCommands.CreateFromDataModel( - batch, - "CustomersTable", // Has 4 columns: CustomerID, Name, Region, Country - "Customers", - "H1", - "CustomersPivot"); - - // Assert - Assert.True(result.Success, $"Expected success but got error: {result.ErrorMessage}"); - Assert.Equal(4, result.AvailableFields.Count); - Assert.Contains("CustomerID", result.AvailableFields); - Assert.Contains("Name", result.AvailableFields); - Assert.Contains("Region", result.AvailableFields); - Assert.Contains("Country", result.AvailableFields); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.Fields.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.Fields.cs deleted file mode 100644 index 9ff7cc87..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.Fields.cs +++ /dev/null @@ -1,318 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.PivotTable; - -/// -/// Tests for PivotTable field operations (Strategy Pattern: RegularPivotTableFieldStrategy). -/// Tests AddColumn, AddValue, AddFilter, Remove, Set* operations on Regular PivotTables. -/// Optimized: Single batch per test, no SaveAsync() unless testing persistence. -/// Organized by category trait for Architecture Pattern clarity. -/// -public partial class PivotTableCommandsTests -{ - /// - [Fact] - [Trait("Speed", "Medium")] - [Trait("Category", "Regular")] - public void AddColumnField_WithValidField_AddsFieldToColumns() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(AddColumnField_WithValidField_AddsFieldToColumns)); - - using var batch = ExcelSession.BeginBatch(testFile); - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F1", "TestPivot"); - Assert.True(createResult.Success); - - // Act - No save needed - var result = _pivotCommands.AddColumnField(batch, "TestPivot", "Product"); - - // Assert - Assert.True(result.Success, $"AddColumnField failed: {result.ErrorMessage}"); - Assert.Equal("Product", result.FieldName); - Assert.Equal(PivotFieldArea.Column, result.Area); - } - /// - - [Fact] - [Trait("Speed", "Medium")] - [Trait("Category", "Regular")] - public void AddValueField_WithValidField_AddsFieldToValues() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(AddValueField_WithValidField_AddsFieldToValues)); - - using var batch = ExcelSession.BeginBatch(testFile); - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F1", "TestPivot"); - Assert.True(createResult.Success); - - // Act - var result = _pivotCommands.AddValueField(batch, "TestPivot", "Sales"); - - // Assert - Assert.True(result.Success, $"AddValueField failed: {result.ErrorMessage}"); - Assert.Equal("Sales", result.FieldName); - Assert.Equal(PivotFieldArea.Value, result.Area); - } - /// - - [Fact] - [Trait("Speed", "Medium")] - [Trait("Category", "Regular")] - public void AddFilterField_WithValidField_AddsFieldToFilters() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(AddFilterField_WithValidField_AddsFieldToFilters)); - - using var batch = ExcelSession.BeginBatch(testFile); - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F1", "TestPivot"); - Assert.True(createResult.Success); - - // Act - var result = _pivotCommands.AddFilterField(batch, "TestPivot", "Region"); - - // Assert - Assert.True(result.Success, $"AddFilterField failed: {result.ErrorMessage}"); - Assert.Equal("Region", result.FieldName); - Assert.Equal(PivotFieldArea.Filter, result.Area); - } - /// - - [Fact] - [Trait("Speed", "Medium")] - [Trait("Category", "Regular")] - public void RemoveField_ExistingField_RemovesFromPivot() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(RemoveField_ExistingField_RemovesFromPivot)); - - using var batch = ExcelSession.BeginBatch(testFile); - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F1", "TestPivot"); - Assert.True(createResult.Success); - - // Add a field first - var addResult = _pivotCommands.AddRowField(batch, "TestPivot", "Region"); - Assert.True(addResult.Success); - - // Act - Remove in same batch - var result = _pivotCommands.RemoveField(batch, "TestPivot", "Region"); - - // Assert - Assert.True(result.Success, $"RemoveField failed: {result.ErrorMessage}"); - - // Verify field removed - var infoResult = _pivotCommands.Read(batch, "TestPivot"); - Assert.True(infoResult.Success); - var regionField = infoResult.Fields.FirstOrDefault(f => f.Name == "Region"); - Assert.NotNull(regionField); - Assert.Equal(PivotFieldArea.Hidden, regionField.Area); - } - /// - - [Fact] - [Trait("Speed", "Medium")] - [Trait("Category", "Regular")] - public void SetFieldFunction_ValueField_ChangesAggregation() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(SetFieldFunction_ValueField_ChangesAggregation)); - - using var batch = ExcelSession.BeginBatch(testFile); - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F1", "TestPivot"); - Assert.True(createResult.Success); - - // Add Sales as value field (default sum) - var addResult = _pivotCommands.AddValueField(batch, "TestPivot", "Sales"); - Assert.True(addResult.Success); - - // Act - Change to Average in same batch - var result = _pivotCommands.SetFieldFunction(batch, "TestPivot", "Sales", AggregationFunction.Average); - - // Assert - Assert.True(result.Success, $"SetFieldFunction failed: {result.ErrorMessage}"); - Assert.Equal("Sales", result.FieldName); - Assert.Equal(AggregationFunction.Average, result.Function); - } - /// - - [Fact] - [Trait("Speed", "Medium")] - [Trait("Category", "Regular")] - public void SetFieldName_ExistingField_RenamesField() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(SetFieldName_ExistingField_RenamesField)); - - using var batch = ExcelSession.BeginBatch(testFile); - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F1", "TestPivot"); - Assert.True(createResult.Success); - - // Add Sales as value field - var addResult = _pivotCommands.AddValueField(batch, "TestPivot", "Sales"); - Assert.True(addResult.Success); - - // Act - var result = _pivotCommands.SetFieldName(batch, "TestPivot", "Sales", "Total Revenue"); - - // Assert - Assert.True(result.Success, $"SetFieldName failed: {result.ErrorMessage}"); - Assert.Equal("Total Revenue", result.CustomName); - } - /// - - [Fact] - [Trait("Speed", "Medium")] - [Trait("Category", "Regular")] - public void SetFieldFormat_ValueField_AppliesNumberFormat() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(SetFieldFormat_ValueField_AppliesNumberFormat)); - - using var batch = ExcelSession.BeginBatch(testFile); - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F1", "TestPivot"); - Assert.True(createResult.Success); - - // Add Sales as value field - var addResult = _pivotCommands.AddValueField(batch, "TestPivot", "Sales"); - Assert.True(addResult.Success); - - // Act - var result = _pivotCommands.SetFieldFormat(batch, "TestPivot", "Sales", "$#,##0.00"); - - // Assert - Assert.True(result.Success, $"SetFieldFormat failed: {result.ErrorMessage}"); - // Note: Excel COM may normalize format codes. We just verify a format was applied and contains currency/decimal indicators - Assert.NotNull(result.NumberFormat); - Assert.Contains("$", result.NumberFormat); - } - - /// - /// Verifies that SetFieldFormat with US currency format works correctly on any locale. - /// The server should auto-translate format codes (number separators handled by UseSystemSeparators=false). - /// - [Fact] - [Trait("Speed", "Medium")] - [Trait("Category", "Regular")] - public void SetFieldFormat_USCurrencyFormat_RoundTripsCorrectly() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(SetFieldFormat_USCurrencyFormat_RoundTripsCorrectly)); - - using var batch = ExcelSession.BeginBatch(testFile); - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F1", "TestPivot"); - Assert.True(createResult.Success); - - // Add Sales as value field - var addResult = _pivotCommands.AddValueField(batch, "TestPivot", "Sales"); - Assert.True(addResult.Success); - - // Act - Apply US currency format - var result = _pivotCommands.SetFieldFormat(batch, "TestPivot", "Sales", "$#,##0.00"); - - // Assert - Format should round-trip correctly (not corrupted by locale) - Assert.True(result.Success, $"SetFieldFormat failed: {result.ErrorMessage}"); - Assert.NotNull(result.NumberFormat); - // Verify the format contains expected components ($ symbol, decimal separator) - Assert.Contains("$", result.NumberFormat); - Assert.Contains(".", result.NumberFormat); // Decimal separator should be preserved - Assert.Contains(",", result.NumberFormat); // Thousands separator should be preserved - } - - /// - /// Verifies that SetFieldFormat with US date format works correctly on value fields. - /// Uses a Count function on a date field to create a numeric value that can be formatted. - /// The server auto-translates format codes (number separators handled by UseSystemSeparators=false). - /// - [Fact] - [Trait("Speed", "Medium")] - [Trait("Category", "Regular")] - public void SetFieldFormat_USPercentFormat_RoundTripsCorrectly() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(SetFieldFormat_USPercentFormat_RoundTripsCorrectly)); - - using var batch = ExcelSession.BeginBatch(testFile); - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F1", "TestPivot"); - Assert.True(createResult.Success); - - // Add Sales as value field - var addResult = _pivotCommands.AddValueField(batch, "TestPivot", "Sales"); - Assert.True(addResult.Success); - - // Act - Apply US percent format (tests decimal separator preservation) - var result = _pivotCommands.SetFieldFormat(batch, "TestPivot", "Sales", "0.00%"); - - // Assert - Format should round-trip correctly (not corrupted by locale) - Assert.True(result.Success, $"SetFieldFormat failed: {result.ErrorMessage}"); - Assert.NotNull(result.NumberFormat); - // Verify the format contains expected components (percent symbol, decimal separator) - Assert.Contains("%", result.NumberFormat); - Assert.Contains(".", result.NumberFormat); // Decimal separator should be preserved - } - /// - - [Fact] - [Trait("Speed", "Medium")] - [Trait("Category", "Regular")] - public void SetFieldFilter_RowField_AppliesFilter() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(SetFieldFilter_RowField_AppliesFilter)); - - using var batch = ExcelSession.BeginBatch(testFile); - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F1", "TestPivot"); - Assert.True(createResult.Success); - - // Add Region as row field - var addResult = _pivotCommands.AddRowField(batch, "TestPivot", "Region"); - Assert.True(addResult.Success); - - // Act - var result = _pivotCommands.SetFieldFilter(batch, "TestPivot", "Region", ["North"]); - - // Assert - Assert.True(result.Success, $"SetFieldFilter failed: {result.ErrorMessage}"); - Assert.Equal("Region", result.FieldName); - Assert.NotEmpty(result.SelectedItems); - } - /// - - [Fact] - [Trait("Speed", "Medium")] - [Trait("Category", "Regular")] - public void SortField_RowField_SortsData() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(SortField_RowField_SortsData)); - - using var batch = ExcelSession.BeginBatch(testFile); - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F1", "TestPivot"); - Assert.True(createResult.Success); - - // Add Region as row field - var addResult = _pivotCommands.AddRowField(batch, "TestPivot", "Region"); - Assert.True(addResult.Success); - - // Act - var result = _pivotCommands.SortField(batch, "TestPivot", "Region", SortDirection.Ascending); - - // Assert - Assert.True(result.Success, $"SortField failed: {result.ErrorMessage}"); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.GrandTotals.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.GrandTotals.cs deleted file mode 100644 index a290303c..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.GrandTotals.cs +++ /dev/null @@ -1,320 +0,0 @@ -using Excel = Microsoft.Office.Interop.Excel; -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.PivotTable; - -/// -/// Tests for SetGrandTotals operation - show/hide row and column grand totals. -/// -public partial class PivotTableCommandsTests -{ - [Fact] - public void SetGrandTotals_ShowBoth_EnablesBothGrandTotals() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(SetGrandTotals_ShowBoth_EnablesBothGrandTotals)); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create PivotTable - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets.Item[1]; - dynamic pivotCache = ctx.Book.PivotCaches().Create(Excel.XlPivotTableSourceType.xlDatabase, sheet.Range["A1:D10"]); - dynamic newSheet = ctx.Book.Worksheets.Add(); - newSheet.Name = "PivotSheet"; - dynamic pivot = pivotCache.CreatePivotTable(newSheet.Range["A1"], "TestPivot"); - - // Add fields - dynamic? rowField = null; - dynamic? colField = null; - dynamic? dataField = null; - try - { - rowField = pivot.PivotFields("Product"); - rowField.Orientation = 1; // xlRowField - - colField = pivot.PivotFields("Region"); - colField.Orientation = 2; // xlColumnField - - dataField = pivot.PivotFields("Sales"); - dataField.Orientation = 4; // xlDataField - - return new { Success = true }; - } - finally - { - ComUtilities.Release(ref rowField); - ComUtilities.Release(ref colField); - ComUtilities.Release(ref dataField); - } - }); - - // Act - var result = _pivotCommands.SetGrandTotals(batch, "TestPivot", true, true); - - // Assert - Assert.True(result.Success, $"Failed: {result.ErrorMessage}"); - } - - [Fact] - public void SetGrandTotals_HideBoth_DisablesBothGrandTotals() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(SetGrandTotals_HideBoth_DisablesBothGrandTotals)); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create PivotTable (grand totals on by default) - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets.Item[1]; - dynamic pivotCache = ctx.Book.PivotCaches().Create(Excel.XlPivotTableSourceType.xlDatabase, sheet.Range["A1:D10"]); - dynamic newSheet = ctx.Book.Worksheets.Add(); - newSheet.Name = "PivotSheet"; - dynamic pivot = pivotCache.CreatePivotTable(newSheet.Range["A1"], "TestPivot"); - - dynamic? rowField = null; - dynamic? colField = null; - dynamic? dataField = null; - try - { - rowField = pivot.PivotFields("Product"); - rowField.Orientation = 1; - - colField = pivot.PivotFields("Region"); - colField.Orientation = 2; - - dataField = pivot.PivotFields("Sales"); - dataField.Orientation = 4; - - return new { Success = true }; - } - finally - { - ComUtilities.Release(ref rowField); - ComUtilities.Release(ref colField); - ComUtilities.Release(ref dataField); - } - }); - - // Act - Hide both grand totals - var result = _pivotCommands.SetGrandTotals(batch, "TestPivot", false, false); - - // Assert - Assert.True(result.Success, $"Failed: {result.ErrorMessage}"); - } - - [Fact] - public void SetGrandTotals_ShowRowHideColumn_EnablesRowDisablesColumnGrandTotals() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(SetGrandTotals_ShowRowHideColumn_EnablesRowDisablesColumnGrandTotals)); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create PivotTable - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets.Item[1]; - dynamic pivotCache = ctx.Book.PivotCaches().Create(Excel.XlPivotTableSourceType.xlDatabase, sheet.Range["A1:D10"]); - dynamic newSheet = ctx.Book.Worksheets.Add(); - newSheet.Name = "PivotSheet"; - dynamic pivot = pivotCache.CreatePivotTable(newSheet.Range["A1"], "TestPivot"); - - dynamic? rowField = null; - dynamic? colField = null; - dynamic? dataField = null; - try - { - rowField = pivot.PivotFields("Product"); - rowField.Orientation = 1; - - colField = pivot.PivotFields("Region"); - colField.Orientation = 2; - - dataField = pivot.PivotFields("Sales"); - dataField.Orientation = 4; - - return new { Success = true }; - } - finally - { - ComUtilities.Release(ref rowField); - ComUtilities.Release(ref colField); - ComUtilities.Release(ref dataField); - } - }); - - // Act - Show row, hide column - var result = _pivotCommands.SetGrandTotals(batch, "TestPivot", true, false); - - // Assert - Assert.True(result.Success, $"Failed: {result.ErrorMessage}"); - } - - [Fact] - public void SetGrandTotals_HideRowShowColumn_DisablesRowEnablesColumnGrandTotals() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(SetGrandTotals_HideRowShowColumn_DisablesRowEnablesColumnGrandTotals)); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create PivotTable - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets.Item[1]; - dynamic pivotCache = ctx.Book.PivotCaches().Create(Excel.XlPivotTableSourceType.xlDatabase, sheet.Range["A1:D10"]); - dynamic newSheet = ctx.Book.Worksheets.Add(); - newSheet.Name = "PivotSheet"; - dynamic pivot = pivotCache.CreatePivotTable(newSheet.Range["A1"], "TestPivot"); - - dynamic? rowField = null; - dynamic? colField = null; - dynamic? dataField = null; - try - { - rowField = pivot.PivotFields("Product"); - rowField.Orientation = 1; - - colField = pivot.PivotFields("Region"); - colField.Orientation = 2; - - dataField = pivot.PivotFields("Sales"); - dataField.Orientation = 4; - - return new { Success = true }; - } - finally - { - ComUtilities.Release(ref rowField); - ComUtilities.Release(ref colField); - ComUtilities.Release(ref dataField); - } - }); - - // Act - Hide row, show column - var result = _pivotCommands.SetGrandTotals(batch, "TestPivot", false, true); - - // Assert - Assert.True(result.Success, $"Failed: {result.ErrorMessage}"); - } - - [Fact] - public void SetGrandTotals_MultipleSequentialChanges_AppliesEachConfiguration() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(SetGrandTotals_MultipleSequentialChanges_AppliesEachConfiguration)); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create PivotTable - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets.Item[1]; - dynamic pivotCache = ctx.Book.PivotCaches().Create(Excel.XlPivotTableSourceType.xlDatabase, sheet.Range["A1:D10"]); - dynamic newSheet = ctx.Book.Worksheets.Add(); - newSheet.Name = "PivotSheet"; - dynamic pivot = pivotCache.CreatePivotTable(newSheet.Range["A1"], "TestPivot"); - - dynamic? rowField = null; - dynamic? colField = null; - dynamic? dataField = null; - try - { - rowField = pivot.PivotFields("Product"); - rowField.Orientation = 1; - - colField = pivot.PivotFields("Region"); - colField.Orientation = 2; - - dataField = pivot.PivotFields("Sales"); - dataField.Orientation = 4; - - return new { Success = true }; - } - finally - { - ComUtilities.Release(ref rowField); - ComUtilities.Release(ref colField); - ComUtilities.Release(ref dataField); - } - }); - - // Act & Assert - Multiple sequential changes - var result1 = _pivotCommands.SetGrandTotals(batch, "TestPivot", true, true); - Assert.True(result1.Success, $"Change 1 failed: {result1.ErrorMessage}"); - - var result2 = _pivotCommands.SetGrandTotals(batch, "TestPivot", false, false); - Assert.True(result2.Success, $"Change 2 failed: {result2.ErrorMessage}"); - - var result3 = _pivotCommands.SetGrandTotals(batch, "TestPivot", true, false); - Assert.True(result3.Success, $"Change 3 failed: {result3.ErrorMessage}"); - - var result4 = _pivotCommands.SetGrandTotals(batch, "TestPivot", false, true); - Assert.True(result4.Success, $"Change 4 failed: {result4.ErrorMessage}"); - } - - [Fact] - public void SetGrandTotals_RoundTrip_PersistsConfiguration() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(SetGrandTotals_RoundTrip_PersistsConfiguration)); - - // Session 1: Create and configure - using (var batch1 = ExcelSession.BeginBatch(testFile)) - { - batch1.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets.Item[1]; - dynamic pivotCache = ctx.Book.PivotCaches().Create(Excel.XlPivotTableSourceType.xlDatabase, sheet.Range["A1:D10"]); - dynamic newSheet = ctx.Book.Worksheets.Add(); - newSheet.Name = "PivotSheet"; - dynamic pivot = pivotCache.CreatePivotTable(newSheet.Range["A1"], "TestPivot"); - - dynamic? rowField = null; - dynamic? colField = null; - dynamic? dataField = null; - try - { - rowField = pivot.PivotFields("Product"); - rowField.Orientation = 1; - - colField = pivot.PivotFields("Region"); - colField.Orientation = 2; - - dataField = pivot.PivotFields("Sales"); - dataField.Orientation = 4; - - return new { Success = true }; - } - finally - { - ComUtilities.Release(ref rowField); - ComUtilities.Release(ref colField); - ComUtilities.Release(ref dataField); - } - }); - - var setResult = _pivotCommands.SetGrandTotals(batch1, "TestPivot", true, false); - Assert.True(setResult.Success, $"SetGrandTotals failed: {setResult.ErrorMessage}"); - - batch1.Save(); - } - - // Session 2: Verify persistence - using (var batch2 = ExcelSession.BeginBatch(testFile)) - { - var readResult = _pivotCommands.Read(batch2, "TestPivot"); - Assert.True(readResult.Success, $"Read failed: {readResult.ErrorMessage}"); - // Configuration persisted successfully (verified by successful read) - } - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.Grouping.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.Grouping.cs deleted file mode 100644 index 07902d0f..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.Grouping.cs +++ /dev/null @@ -1,400 +0,0 @@ -using Microsoft.Extensions.Logging; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.PivotTable; - -public partial class PivotTableCommandsTests -{ - /// - /// Tests date grouping by Months interval creates proper monthly groups in PivotTable. - /// - [Fact] - [Trait("Speed", "Medium")] - public void GroupByDate_MonthsInterval_CreatesMonthlyGroups() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(GroupByDate_MonthsInterval_CreatesMonthlyGroups)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - // Create PivotTable - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F2", "MonthlySales"); - Assert.True(createResult.Success, $"Failed to create PivotTable: {createResult.ErrorMessage}"); - - // Add Date to Row area - var addDateResult = _pivotCommands.AddRowField(batch, "MonthlySales", "Date"); - Assert.True(addDateResult.Success, $"Failed to add Date field: {addDateResult.ErrorMessage}"); - - // Add Sales to Value area - var addValueResult = _pivotCommands.AddValueField(batch, "MonthlySales", "Sales"); - Assert.True(addValueResult.Success, $"Failed to add Sales field: {addValueResult.ErrorMessage}"); - - // Act - Group Date by Months - var groupResult = _pivotCommands.GroupByDate(batch, "MonthlySales", "Date", DateGroupingInterval.Months); - - // Assert - Assert.True(groupResult.Success, $"GroupByDate failed: {groupResult.ErrorMessage}"); - Assert.Equal("Date", groupResult.FieldName); - Assert.NotNull(groupResult.WorkflowHint); - Assert.Contains("Months", groupResult.WorkflowHint); - - // Verify grouping created hierarchy by checking field list - var listResult = _pivotCommands.ListFields(batch, "MonthlySales"); - Assert.True(listResult.Success, $"Failed to list fields: {listResult.ErrorMessage}"); - - // DIAGNOSTIC: Print all field names to understand what Excel created - var fieldNames = string.Join(", ", listResult.Fields?.Select(f => f.Name) ?? Array.Empty()); - _output.WriteLine($"Fields after grouping: {fieldNames}"); - - // Excel creates "Months" field when grouping by months - var hasMonthsField = listResult.Fields?.Any(f => f.Name?.Contains("Month", StringComparison.OrdinalIgnoreCase) == true) == true; - Assert.True(hasMonthsField, $"Expected to find Months field after grouping. Actual fields: {fieldNames}"); - } - - /// - /// Tests date grouping by Days interval creates proper daily groups in PivotTable. - /// - [Fact] - [Trait("Speed", "Medium")] - public void GroupByDate_DaysInterval_CreatesDailyGroups() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(GroupByDate_DaysInterval_CreatesDailyGroups)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - // Create PivotTable - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F2", "DailySales"); - Assert.True(createResult.Success, $"Failed to create PivotTable: {createResult.ErrorMessage}"); - - // Add Date to Row area - var addDateResult = _pivotCommands.AddRowField(batch, "DailySales", "Date"); - Assert.True(addDateResult.Success, $"Failed to add Date field: {addDateResult.ErrorMessage}"); - - // Add Sales to Value area - var addValueResult = _pivotCommands.AddValueField(batch, "DailySales", "Sales"); - Assert.True(addValueResult.Success, $"Failed to add Sales field: {addValueResult.ErrorMessage}"); - - // Act - Group Date by Days - var groupResult = _pivotCommands.GroupByDate(batch, "DailySales", "Date", DateGroupingInterval.Days); - - // Assert - Assert.True(groupResult.Success, $"GroupByDate failed: {groupResult.ErrorMessage}"); - Assert.Equal("Date", groupResult.FieldName); - Assert.NotNull(groupResult.WorkflowHint); - Assert.Contains("Days", groupResult.WorkflowHint); - - // Verify grouping created hierarchy by checking field list - var listResult = _pivotCommands.ListFields(batch, "DailySales"); - Assert.True(listResult.Success, $"Failed to list fields: {listResult.ErrorMessage}"); - - var fieldNames = string.Join(", ", listResult.Fields?.Select(f => f.Name) ?? Array.Empty()); - _output.WriteLine($"Fields after grouping: {fieldNames}"); - - // Excel creates "Days" field when grouping by days - var hasDaysField = listResult.Fields?.Any(f => f.Name?.Contains("Day", StringComparison.OrdinalIgnoreCase) == true) == true; - Assert.True(hasDaysField, $"Expected to find Days field after grouping. Actual fields: {fieldNames}"); - } - - /// - /// Tests date grouping by Quarters interval creates proper quarterly groups in PivotTable. - /// - [Fact] - [Trait("Speed", "Medium")] - public void GroupByDate_QuartersInterval_CreatesQuarterlyGroups() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(GroupByDate_QuartersInterval_CreatesQuarterlyGroups)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - // Create PivotTable - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F2", "QuarterlySales"); - Assert.True(createResult.Success, $"Failed to create PivotTable: {createResult.ErrorMessage}"); - - // Add Date to Row area - var addDateResult = _pivotCommands.AddRowField(batch, "QuarterlySales", "Date"); - Assert.True(addDateResult.Success, $"Failed to add Date field: {addDateResult.ErrorMessage}"); - - // Add Sales to Value area - var addValueResult = _pivotCommands.AddValueField(batch, "QuarterlySales", "Sales"); - Assert.True(addValueResult.Success, $"Failed to add Sales field: {addValueResult.ErrorMessage}"); - - // Act - Group Date by Quarters - var groupResult = _pivotCommands.GroupByDate(batch, "QuarterlySales", "Date", DateGroupingInterval.Quarters); - - // Assert - Assert.True(groupResult.Success, $"GroupByDate failed: {groupResult.ErrorMessage}"); - Assert.Equal("Date", groupResult.FieldName); - Assert.NotNull(groupResult.WorkflowHint); - Assert.Contains("Quarters", groupResult.WorkflowHint); - - // Verify grouping created hierarchy by checking field list - var listResult = _pivotCommands.ListFields(batch, "QuarterlySales"); - Assert.True(listResult.Success, $"Failed to list fields: {listResult.ErrorMessage}"); - - var fieldNames = string.Join(", ", listResult.Fields?.Select(f => f.Name) ?? Array.Empty()); - _output.WriteLine($"Fields after grouping: {fieldNames}"); - - // Excel creates "Quarters" field when grouping by quarters - var hasQuartersField = listResult.Fields?.Any(f => f.Name?.Contains("Quarter", StringComparison.OrdinalIgnoreCase) == true) == true; - Assert.True(hasQuartersField, $"Expected to find Quarters field after grouping. Actual fields: {fieldNames}"); - } - - /// - /// Tests date grouping by Years interval creates proper yearly groups in PivotTable. - /// - [Fact] - [Trait("Speed", "Medium")] - public void GroupByDate_YearsInterval_CreatesYearlyGroups() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(GroupByDate_YearsInterval_CreatesYearlyGroups)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - // Create PivotTable - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F2", "YearlySales"); - Assert.True(createResult.Success, $"Failed to create PivotTable: {createResult.ErrorMessage}"); - - // Add Date to Row area - var addDateResult = _pivotCommands.AddRowField(batch, "YearlySales", "Date"); - Assert.True(addDateResult.Success, $"Failed to add Date field: {addDateResult.ErrorMessage}"); - - // Add Sales to Value area - var addValueResult = _pivotCommands.AddValueField(batch, "YearlySales", "Sales"); - Assert.True(addValueResult.Success, $"Failed to add Sales field: {addValueResult.ErrorMessage}"); - - // Act - Group Date by Years - var groupResult = _pivotCommands.GroupByDate(batch, "YearlySales", "Date", DateGroupingInterval.Years); - - // Assert - Assert.True(groupResult.Success, $"GroupByDate failed: {groupResult.ErrorMessage}"); - Assert.Equal("Date", groupResult.FieldName); - Assert.NotNull(groupResult.WorkflowHint); - Assert.Contains("Years", groupResult.WorkflowHint); - - // Verify grouping created hierarchy by checking field list - var listResult = _pivotCommands.ListFields(batch, "YearlySales"); - Assert.True(listResult.Success, $"Failed to list fields: {listResult.ErrorMessage}"); - - var fieldNames = string.Join(", ", listResult.Fields?.Select(f => f.Name) ?? Array.Empty()); - _output.WriteLine($"Fields after grouping: {fieldNames}"); - - // Excel creates "Years" field when grouping by years - var hasYearsField = listResult.Fields?.Any(f => f.Name?.Contains("Year", StringComparison.OrdinalIgnoreCase) == true) == true; - Assert.True(hasYearsField, $"Expected to find Years field after grouping. Actual fields: {fieldNames}"); - } - - /// - /// Tests numeric grouping with auto-range (uses field min/max) creates proper numeric groups. - /// - [Fact] - [Trait("Speed", "Medium")] - public void GroupByNumeric_AutoRange_CreatesNumericGroups() - { - // Arrange - var testFile = CreateTestFileWithNumericData(nameof(GroupByNumeric_AutoRange_CreatesNumericGroups)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - // Create PivotTable - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F2", "SalesByRange"); - Assert.True(createResult.Success, $"Failed to create PivotTable: {createResult.ErrorMessage}"); - - // Add Sales to Row area - var addSalesResult = _pivotCommands.AddRowField(batch, "SalesByRange", "Sales"); - Assert.True(addSalesResult.Success, $"Failed to add Sales field: {addSalesResult.ErrorMessage}"); - - // Add Region to Value area (Count) - var addValueResult = _pivotCommands.AddValueField(batch, "SalesByRange", "Region", AggregationFunction.Count); - Assert.True(addValueResult.Success, $"Failed to add Region field: {addValueResult.ErrorMessage}"); - - // Act - Group Sales by 100 with auto-range - var groupResult = _pivotCommands.GroupByNumeric(batch, "SalesByRange", "Sales", start: null, endValue: null, intervalSize: 100); - - // Assert - Assert.True(groupResult.Success, $"GroupByNumeric failed: {groupResult.ErrorMessage}"); - Assert.Equal("Sales", groupResult.FieldName); - Assert.NotNull(groupResult.WorkflowHint); - Assert.Contains("100", groupResult.WorkflowHint); - - // Verify grouping created groups by checking field list - var listResult = _pivotCommands.ListFields(batch, "SalesByRange"); - Assert.True(listResult.Success, $"Failed to list fields: {listResult.ErrorMessage}"); - - var fieldNames = string.Join(", ", listResult.Fields?.Select(f => f.Name) ?? Array.Empty()); - _output.WriteLine($"Fields after numeric grouping: {fieldNames}"); - - // After grouping, field should still be named "Sales" but contain grouped values - var hasSalesField = listResult.Fields?.Any(f => f.Name == "Sales") == true; - Assert.True(hasSalesField, $"Expected to find Sales field after grouping. Actual fields: {fieldNames}"); - } - - /// - /// Tests numeric grouping with custom range creates proper numeric groups. - /// - [Fact] - [Trait("Speed", "Medium")] - public void GroupByNumeric_CustomRange_CreatesNumericGroups() - { - // Arrange - var testFile = CreateTestFileWithNumericData(nameof(GroupByNumeric_CustomRange_CreatesNumericGroups)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - // Create PivotTable - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F2", "SalesByCustomRange"); - Assert.True(createResult.Success, $"Failed to create PivotTable: {createResult.ErrorMessage}"); - - // Add Sales to Row area - var addSalesResult = _pivotCommands.AddRowField(batch, "SalesByCustomRange", "Sales"); - Assert.True(addSalesResult.Success, $"Failed to add Sales field: {addSalesResult.ErrorMessage}"); - - // Add Region to Value area (Count) - var addValueResult = _pivotCommands.AddValueField(batch, "SalesByCustomRange", "Region", AggregationFunction.Count); - Assert.True(addValueResult.Success, $"Failed to add Region field: {addValueResult.ErrorMessage}"); - - // Act - Group Sales 0-1000 by 200 - var groupResult = _pivotCommands.GroupByNumeric(batch, "SalesByCustomRange", "Sales", start: 0, endValue: 1000, intervalSize: 200); - - // Assert - Assert.True(groupResult.Success, $"GroupByNumeric failed: {groupResult.ErrorMessage}"); - Assert.Equal("Sales", groupResult.FieldName); - Assert.NotNull(groupResult.WorkflowHint); - Assert.Contains("200", groupResult.WorkflowHint); - - // Verify grouping created groups - var listResult = _pivotCommands.ListFields(batch, "SalesByCustomRange"); - Assert.True(listResult.Success, $"Failed to list fields: {listResult.ErrorMessage}"); - - var fieldNames = string.Join(", ", listResult.Fields?.Select(f => f.Name) ?? Array.Empty()); - _output.WriteLine($"Fields after custom range grouping: {fieldNames}"); - - var hasSalesField = listResult.Fields?.Any(f => f.Name == "Sales") == true; - Assert.True(hasSalesField, $"Expected to find Sales field after grouping. Actual fields: {fieldNames}"); - } - - /// - /// Tests numeric grouping with small interval creates fine-grained groups. - /// - [Fact] - [Trait("Speed", "Medium")] - public void GroupByNumeric_SmallInterval_CreatesFineGrainedGroups() - { - // Arrange - var testFile = CreateTestFileWithNumericData(nameof(GroupByNumeric_SmallInterval_CreatesFineGrainedGroups)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - // Create PivotTable - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F2", "SalesBySmallRange"); - Assert.True(createResult.Success, $"Failed to create PivotTable: {createResult.ErrorMessage}"); - - // Add Sales to Row area - var addSalesResult = _pivotCommands.AddRowField(batch, "SalesBySmallRange", "Sales"); - Assert.True(addSalesResult.Success, $"Failed to add Sales field: {addSalesResult.ErrorMessage}"); - - // Add Region to Value area (Count) - var addValueResult = _pivotCommands.AddValueField(batch, "SalesBySmallRange", "Region", AggregationFunction.Count); - Assert.True(addValueResult.Success, $"Failed to add Region field: {addValueResult.ErrorMessage}"); - - // Act - Group Sales by 50 for fine-grained analysis - var groupResult = _pivotCommands.GroupByNumeric(batch, "SalesBySmallRange", "Sales", start: null, endValue: null, intervalSize: 50); - - // Assert - Assert.True(groupResult.Success, $"GroupByNumeric failed: {groupResult.ErrorMessage}"); - Assert.Equal("Sales", groupResult.FieldName); - Assert.NotNull(groupResult.WorkflowHint); - Assert.Contains("50", groupResult.WorkflowHint); - - // Verify grouping created groups - var listResult = _pivotCommands.ListFields(batch, "SalesBySmallRange"); - Assert.True(listResult.Success, $"Failed to list fields: {listResult.ErrorMessage}"); - - var fieldNames = string.Join(", ", listResult.Fields?.Select(f => f.Name) ?? Array.Empty()); - _output.WriteLine($"Fields after small interval grouping: {fieldNames}"); - - var hasSalesField = listResult.Fields?.Any(f => f.Name == "Sales") == true; - Assert.True(hasSalesField, $"Expected to find Sales field after grouping. Actual fields: {fieldNames}"); - } - - /// - /// Helper method to create test file with numeric Sales data for grouping tests. - /// - private string CreateTestFileWithNumericData(string testName) - { - var testFile = _fixture.CreateTestFile(testName); - - using var batch = ExcelSession.BeginBatch(testFile); - - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - sheet.Name = "SalesData"; - - sheet.Range["A1"].Value2 = "Region"; - sheet.Range["B1"].Value2 = "Product"; - sheet.Range["C1"].Value2 = "Sales"; - sheet.Range["D1"].Value2 = "Date"; - - sheet.Range["A2"].Value2 = "North"; - sheet.Range["B2"].Value2 = "Widget"; - sheet.Range["C2"].Value2 = 150; - sheet.Range["D2"].Value2 = new DateTime(2025, 1, 15); - - sheet.Range["A3"].Value2 = "North"; - sheet.Range["B3"].Value2 = "Widget"; - sheet.Range["C3"].Value2 = 250; - sheet.Range["D3"].Value2 = new DateTime(2025, 1, 20); - - sheet.Range["A4"].Value2 = "South"; - sheet.Range["B4"].Value2 = "Gadget"; - sheet.Range["C4"].Value2 = 450; - sheet.Range["D4"].Value2 = new DateTime(2025, 2, 10); - - sheet.Range["A5"].Value2 = "North"; - sheet.Range["B5"].Value2 = "Gadget"; - sheet.Range["C5"].Value2 = 600; - sheet.Range["D5"].Value2 = new DateTime(2025, 2, 15); - - sheet.Range["A6"].Value2 = "South"; - sheet.Range["B6"].Value2 = "Widget"; - sheet.Range["C6"].Value2 = 850; - sheet.Range["D6"].Value2 = new DateTime(2025, 3, 5); - - // Format Sales column with numeric format (similar to date formatting requirement) - sheet.Range["C2:C6"].NumberFormat = "0"; - - // Format Date column with date format - sheet.Range["D2:D6"].NumberFormat = "m/d/yyyy"; - - return 0; - }); - - batch.Save(); - - return testFile; - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.LayoutOnCreate.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.LayoutOnCreate.cs deleted file mode 100644 index a13c8169..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.LayoutOnCreate.cs +++ /dev/null @@ -1,211 +0,0 @@ -using Microsoft.Extensions.Logging; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands.Table; -using Sbroenne.ExcelMcp.Core.Models; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.PivotTable; - -/// -/// Tests for PivotTable layout applied during create operations (Issue #366). -/// These tests verify that layout can be set immediately after PivotTable creation -/// in a single batch operation. -/// -public partial class PivotTableCommandsTests -{ - #region CreateFromRange with Layout - - [Fact] - [Trait("Speed", "Medium")] - public void CreateFromRange_WithTabularLayout_AppliesLayoutDuringCreate() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(CreateFromRange_WithTabularLayout_AppliesLayoutDuringCreate)); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - Create pivot - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F1", "LayoutPivot"); - Assert.True(createResult.Success, $"CreateFromRange failed: {createResult.ErrorMessage}"); - - // Apply layout immediately after creation (same batch - simulates what MCP tool will do) - var layoutResult = _pivotCommands.SetLayout(batch, "LayoutPivot", 1); // Tabular - - // Assert - Layout applied successfully - Assert.True(layoutResult.Success, $"SetLayout failed: {layoutResult.ErrorMessage}"); - } - - [Fact] - [Trait("Speed", "Medium")] - public void CreateFromRange_WithCompactLayout_AppliesLayoutDuringCreate() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(CreateFromRange_WithCompactLayout_AppliesLayoutDuringCreate)); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - Create pivot and set layout - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F1", "LayoutPivot"); - Assert.True(createResult.Success); - - var layoutResult = _pivotCommands.SetLayout(batch, "LayoutPivot", 0); // Compact - - // Assert - Assert.True(layoutResult.Success, $"SetLayout failed: {layoutResult.ErrorMessage}"); - } - - [Fact] - [Trait("Speed", "Medium")] - public void CreateFromRange_WithOutlineLayout_AppliesLayoutDuringCreate() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(CreateFromRange_WithOutlineLayout_AppliesLayoutDuringCreate)); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - Create pivot and set layout - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F1", "LayoutPivot"); - Assert.True(createResult.Success); - - var layoutResult = _pivotCommands.SetLayout(batch, "LayoutPivot", 2); // Outline - - // Assert - Assert.True(layoutResult.Success, $"SetLayout failed: {layoutResult.ErrorMessage}"); - } - - [Fact] - [Trait("Speed", "Medium")] - public void CreateFromRange_LayoutAndFields_BothApplyInSameBatch() - { - // Arrange - This test verifies the full workflow: create + layout + fields - var testFile = CreateTestFileWithData(nameof(CreateFromRange_LayoutAndFields_BothApplyInSameBatch)); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - Create pivot, set layout, add fields (all in same batch) - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F1", "LayoutPivot"); - Assert.True(createResult.Success, $"CreateFromRange failed: {createResult.ErrorMessage}"); - - var layoutResult = _pivotCommands.SetLayout(batch, "LayoutPivot", 1); // Tabular - Assert.True(layoutResult.Success, $"SetLayout failed: {layoutResult.ErrorMessage}"); - - var row1 = _pivotCommands.AddRowField(batch, "LayoutPivot", "Region"); - Assert.True(row1.Success, $"AddRowField Region failed: {row1.ErrorMessage}"); - - var row2 = _pivotCommands.AddRowField(batch, "LayoutPivot", "Product"); - Assert.True(row2.Success, $"AddRowField Product failed: {row2.ErrorMessage}"); - - var value = _pivotCommands.AddValueField(batch, "LayoutPivot", "Sales"); - Assert.True(value.Success, $"AddValueField failed: {value.ErrorMessage}"); - - // Assert - Verify all operations succeeded - var readResult = _pivotCommands.Read(batch, "LayoutPivot"); - Assert.True(readResult.Success); - Assert.Equal("LayoutPivot", readResult.PivotTable!.Name); - } - - #endregion - - #region CreateFromTable with Layout - - [Fact] - [Trait("Speed", "Medium")] - public void CreateFromTable_WithTabularLayout_AppliesLayoutDuringCreate() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(CreateFromTable_WithTabularLayout_AppliesLayoutDuringCreate)); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create table first - var tableCommands = new TableCommands(); - tableCommands.Create(batch, "SalesData", "SalesTable", "A1:D6", true, TableStylePresets.Medium2); - - // Act - Create pivot from table and set layout - var createResult = _pivotCommands.CreateFromTable( - batch, "SalesTable", "SalesData", "F1", "TableLayoutPivot"); - Assert.True(createResult.Success, $"CreateFromTable failed: {createResult.ErrorMessage}"); - - var layoutResult = _pivotCommands.SetLayout(batch, "TableLayoutPivot", 1); // Tabular - - // Assert - Assert.True(layoutResult.Success, $"SetLayout failed: {layoutResult.ErrorMessage}"); - } - - [Fact] - [Trait("Speed", "Medium")] - public void CreateFromTable_WithOutlineLayout_AppliesLayoutDuringCreate() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(CreateFromTable_WithOutlineLayout_AppliesLayoutDuringCreate)); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create table first - var tableCommands = new TableCommands(); - tableCommands.Create(batch, "SalesData", "SalesTable", "A1:D6", true, TableStylePresets.Medium2); - - // Act - Create pivot from table and set layout - var createResult = _pivotCommands.CreateFromTable( - batch, "SalesTable", "SalesData", "F1", "TableLayoutPivot"); - Assert.True(createResult.Success); - - var layoutResult = _pivotCommands.SetLayout(batch, "TableLayoutPivot", 2); // Outline - - // Assert - Assert.True(layoutResult.Success, $"SetLayout failed: {layoutResult.ErrorMessage}"); - } - - #endregion - - #region Persistence Tests - - [Fact] - [Trait("Speed", "Medium")] - public void CreateFromRange_LayoutAndFields_PersistsAfterSaveAndReopen() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(CreateFromRange_LayoutAndFields_PersistsAfterSaveAndReopen)); - - // Act - Create, configure, and save - using (var batch = ExcelSession.BeginBatch(testFile)) - { - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F1", "PersistPivot"); - Assert.True(createResult.Success); - - var layoutResult = _pivotCommands.SetLayout(batch, "PersistPivot", 1); // Tabular - Assert.True(layoutResult.Success); - - var row = _pivotCommands.AddRowField(batch, "PersistPivot", "Region"); - Assert.True(row.Success); - - var value = _pivotCommands.AddValueField(batch, "PersistPivot", "Sales"); - Assert.True(value.Success); - - batch.Save(); - } - - // Assert - Reopen and verify - using (var batch = ExcelSession.BeginBatch(testFile)) - { - var listResult = _pivotCommands.List(batch); - Assert.True(listResult.Success); - Assert.Contains(listResult.PivotTables, pt => pt.Name == "PersistPivot"); - - var fields = _pivotCommands.ListFields(batch, "PersistPivot"); - Assert.True(fields.Success); - Assert.Contains(fields.Fields, f => f.Name == "Region"); - } - } - - #endregion -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.LayoutSubtotals.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.LayoutSubtotals.cs deleted file mode 100644 index 11be3688..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.LayoutSubtotals.cs +++ /dev/null @@ -1,203 +0,0 @@ -using Microsoft.Extensions.Logging; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.PivotTable; - -/// -/// Tests for PivotTable layout and subtotals operations. -/// -public partial class PivotTableCommandsTests -{ - [Fact] - [Trait("Speed", "Medium")] - public void SetLayout_Compact_UpdatesLayoutForm() - { - // Arrange - Create test file with data - var testFile = CreateTestFileWithData(nameof(SetLayout_Compact_UpdatesLayoutForm)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - var createResult = _pivotCommands.CreateFromRange(batch, "SalesData", "A1:D6", "SalesData", "F2", "SalesPivot"); - Assert.True(createResult.Success, $"CreateFromRange failed: {createResult.ErrorMessage}"); - - var row1 = _pivotCommands.AddRowField(batch, "SalesPivot", "Region"); - Assert.True(row1.Success, $"AddRowField Region failed: {row1.ErrorMessage}"); - - var row2 = _pivotCommands.AddRowField(batch, "SalesPivot", "Product"); - Assert.True(row2.Success, $"AddRowField Product failed: {row2.ErrorMessage}"); - - var value = _pivotCommands.AddValueField(batch, "SalesPivot", "Sales"); - Assert.True(value.Success, $"AddValueField failed: {value.ErrorMessage}"); - - // Act - Set to Compact layout - var result = _pivotCommands.SetLayout(batch, "SalesPivot", 0); - - // Assert - Assert.True(result.Success, $"SetLayout failed: {result.ErrorMessage}"); - } - - [Fact] - [Trait("Speed", "Medium")] - public void SetLayout_Tabular_UpdatesLayoutForm() - { - // Arrange - Create test file with data - var testFile = CreateTestFileWithData(nameof(SetLayout_Tabular_UpdatesLayoutForm)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - var createResult = _pivotCommands.CreateFromRange(batch, "SalesData", "A1:D6", "SalesData", "F2", "SalesPivot"); - Assert.True(createResult.Success, $"CreateFromRange failed: {createResult.ErrorMessage}"); - - var row1 = _pivotCommands.AddRowField(batch, "SalesPivot", "Region"); - Assert.True(row1.Success, $"AddRowField Region failed: {row1.ErrorMessage}"); - - var row2 = _pivotCommands.AddRowField(batch, "SalesPivot", "Product"); - Assert.True(row2.Success, $"AddRowField Product failed: {row2.ErrorMessage}"); - - var value = _pivotCommands.AddValueField(batch, "SalesPivot", "Sales"); - Assert.True(value.Success, $"AddValueField failed: {value.ErrorMessage}"); - - // Act - Set to Tabular layout - var result = _pivotCommands.SetLayout(batch, "SalesPivot", 1); - - // Assert - Assert.True(result.Success, $"SetLayout failed: {result.ErrorMessage}"); - } - - [Fact] - [Trait("Speed", "Medium")] - public void SetLayout_Outline_UpdatesLayoutForm() - { - // Arrange - Create test file with data - var testFile = CreateTestFileWithData(nameof(SetLayout_Outline_UpdatesLayoutForm)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - var createResult = _pivotCommands.CreateFromRange(batch, "SalesData", "A1:D6", "SalesData", "F2", "SalesPivot"); - Assert.True(createResult.Success, $"CreateFromRange failed: {createResult.ErrorMessage}"); - - var row1 = _pivotCommands.AddRowField(batch, "SalesPivot", "Region"); - Assert.True(row1.Success, $"AddRowField Region failed: {row1.ErrorMessage}"); - - var row2 = _pivotCommands.AddRowField(batch, "SalesPivot", "Product"); - Assert.True(row2.Success, $"AddRowField Product failed: {row2.ErrorMessage}"); - - var value = _pivotCommands.AddValueField(batch, "SalesPivot", "Sales"); - Assert.True(value.Success, $"AddValueField failed: {value.ErrorMessage}"); - - // Act - Set to Outline layout - var result = _pivotCommands.SetLayout(batch, "SalesPivot", 2); - - // Assert - Assert.True(result.Success, $"SetLayout failed: {result.ErrorMessage}"); - } - - [Fact] - [Trait("Speed", "Medium")] - public void SetSubtotals_Show_EnablesSubtotals() - { - // Arrange - Create test file with data - var testFile = CreateTestFileWithData(nameof(SetSubtotals_Show_EnablesSubtotals)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - var createResult = _pivotCommands.CreateFromRange(batch, "SalesData", "A1:D6", "SalesData", "F2", "SalesPivot"); - Assert.True(createResult.Success, $"CreateFromRange failed: {createResult.ErrorMessage}"); - - var row = _pivotCommands.AddRowField(batch, "SalesPivot", "Region"); - Assert.True(row.Success, $"AddRowField failed: {row.ErrorMessage}"); - - var value = _pivotCommands.AddValueField(batch, "SalesPivot", "Sales"); - Assert.True(value.Success, $"AddValueField failed: {value.ErrorMessage}"); - - // Act - Enable subtotals - var result = _pivotCommands.SetSubtotals(batch, "SalesPivot", "Region", true); - - // Assert - Assert.True(result.Success, $"SetSubtotals failed: {result.ErrorMessage}"); - Assert.Equal("Region", result.FieldName); - } - - [Fact] - [Trait("Speed", "Medium")] - public void SetSubtotals_Hide_DisablesSubtotals() - { - // Arrange - Create test file with data - var testFile = CreateTestFileWithData(nameof(SetSubtotals_Hide_DisablesSubtotals)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - var createResult = _pivotCommands.CreateFromRange(batch, "SalesData", "A1:D6", "SalesData", "F2", "SalesPivot"); - Assert.True(createResult.Success, $"CreateFromRange failed: {createResult.ErrorMessage}"); - - var row = _pivotCommands.AddRowField(batch, "SalesPivot", "Region"); - Assert.True(row.Success, $"AddRowField failed: {row.ErrorMessage}"); - - var value = _pivotCommands.AddValueField(batch, "SalesPivot", "Sales"); - Assert.True(value.Success, $"AddValueField failed: {value.ErrorMessage}"); - - // First enable subtotals - var enable = _pivotCommands.SetSubtotals(batch, "SalesPivot", "Region", true); - Assert.True(enable.Success); - - // Act - Disable subtotals - var result = _pivotCommands.SetSubtotals(batch, "SalesPivot", "Region", false); - - // Assert - Assert.True(result.Success, $"SetSubtotals failed: {result.ErrorMessage}"); - Assert.Equal("Region", result.FieldName); - } - - [Fact] - [Trait("Speed", "Medium")] - public void SetLayout_RoundTrip_PersistsLayoutChange() - { - // Arrange - Create test file with data - var testFile = CreateTestFileWithData(nameof(SetLayout_RoundTrip_PersistsLayoutChange)); - - using (var batch = new ExcelBatch(new[] { testFile }, _loggerFactory.CreateLogger())) - { - var createResult = _pivotCommands.CreateFromRange(batch, "SalesData", "A1:D6", "SalesData", "F2", "SalesPivot"); - Assert.True(createResult.Success); - - var row1 = _pivotCommands.AddRowField(batch, "SalesPivot", "Region"); - Assert.True(row1.Success); - - var row2 = _pivotCommands.AddRowField(batch, "SalesPivot", "Product"); - Assert.True(row2.Success); - - var value = _pivotCommands.AddValueField(batch, "SalesPivot", "Sales"); - Assert.True(value.Success); - - // Act - Set to Tabular layout and save - var layoutResult = _pivotCommands.SetLayout(batch, "SalesPivot", 1); - Assert.True(layoutResult.Success); - - batch.Save(); - } - - // Assert - Reopen and verify PivotTable still exists and configured - using (var batch = new ExcelBatch(new[] { testFile }, _loggerFactory.CreateLogger())) - { - var listResult = _pivotCommands.List(batch); - Assert.True(listResult.Success); - Assert.Contains(listResult.PivotTables, pt => pt.Name == "SalesPivot"); - - // Verify we can still interact with the PivotTable - var fields = _pivotCommands.ListFields(batch, "SalesPivot"); - Assert.True(fields.Success); - Assert.Contains(fields.Fields, f => f.Name == "Region"); - Assert.Contains(fields.Fields, f => f.Name == "Product"); - } - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.OlapFields.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.OlapFields.cs deleted file mode 100644 index 3523b6bc..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.OlapFields.cs +++ /dev/null @@ -1,397 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands; -using Sbroenne.ExcelMcp.Core.Models; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.PivotTable; - -/// -/// Tests for OLAP/Data Model PivotTable field operations (Strategy Pattern: OlapPivotTableFieldStrategy). -/// Verifies that all field manipulation methods work correctly with Data Model PivotTables. -/// Uses CubeFields API via GetFieldForManipulation() helper. -/// Organized as partial class for consistency with Strategy Pattern architecture. -/// -public partial class PivotTableCommandsTests -{ - /// - /// OLAP-specific tests use fixture to provide Data Model PivotTable. - /// All OLAP tests marked with [Trait("Category", "OLAP")] for strategy classification. - /// - - [Fact] - [Trait("Speed", "Medium")] - [Trait("Category", "OLAP")] - public void AddRowField_OlapPivot_AddsFieldToRows() - { - // Arrange - Create OLAP test file with data model - var olapTestFile = CreateOlapTestFile(nameof(AddRowField_OlapPivot_AddsFieldToRows)); - using var batch = ExcelSession.BeginBatch(olapTestFile); - - // Act - Remove existing Region field first, then add Quarter - // Use exact CubeField names (LLM discovers via ListFields) - _pivotCommands.RemoveField(batch, "DataModelPivot", "[RegionalSalesTable].[Region]"); - var result = _pivotCommands.AddRowField(batch, "DataModelPivot", "[RegionalSalesTable].[Quarter]", null); - - // Assert - Assert.True(result.Success, $"Failed: {result.ErrorMessage}"); - Assert.Equal("[RegionalSalesTable].[Quarter]", result.FieldName); - Assert.Equal(PivotFieldArea.Row, result.Area); - } - - [Fact] - [Trait("Speed", "Medium")] - [Trait("Category", "OLAP")] - public void AddColumnField_OlapPivot_AddsFieldToColumns() - { - // Arrange - Create OLAP test file with data model - var olapTestFile = CreateOlapTestFile(nameof(AddColumnField_OlapPivot_AddsFieldToColumns)); - using var batch = ExcelSession.BeginBatch(olapTestFile); - - // Act - Remove existing Region field first to make room for Quarter - // Use exact CubeField names (LLM discovers via ListFields) - _pivotCommands.RemoveField(batch, "DataModelPivot", "[RegionalSalesTable].[Region]"); - var result = _pivotCommands.AddColumnField(batch, "DataModelPivot", "[RegionalSalesTable].[Quarter]", null); - - // Assert - Assert.True(result.Success, $"Failed: {result.ErrorMessage}"); - Assert.Equal("[RegionalSalesTable].[Quarter]", result.FieldName); - Assert.Equal(PivotFieldArea.Column, result.Area); - } - - [Fact] - [Trait("Speed", "Medium")] - [Trait("Category", "OLAP")] - public void SortField_OlapPivot_SortsFieldSuccessfully() - { - // Arrange - Create OLAP test file with data model - var olapTestFile = CreateOlapTestFile(nameof(SortField_OlapPivot_SortsFieldSuccessfully)); - using var batch = ExcelSession.BeginBatch(olapTestFile); - - // Act - Region row field exists in fixture - // Use exact CubeField name (LLM discovers via ListFields) - var result = _pivotCommands.SortField( - batch, - "DataModelPivot", - "[RegionalSalesTable].[Region]", - SortDirection.Descending); - - // Assert - Assert.True(result.Success, $"Failed: {result.ErrorMessage}"); - Assert.Equal("[RegionalSalesTable].[Region]", result.FieldName); - } - - /// - /// Regression test for Issue #217: Auto-create DAX measures when adding value fields to OLAP PivotTables. - /// - /// CURRENT BEHAVIOR: AddValueField on OLAP PivotTable always fails with: - /// "Cannot add value field to OLAP PivotTable. OLAP measures must be pre-defined..." - /// - /// EXPECTED BEHAVIOR: AddValueField should auto-create DAX measure and add to values area. - /// - /// This test is expected to FAIL initially, then PASS after implementing auto-DAX-creation. - /// - [Fact] - [Trait("Speed", "Medium")] - [Trait("Category", "OLAP")] - public void AddValueField_OlapPivot_AutoCreatesDaxMeasure() - { - // Arrange - Create OLAP test file with Data Model PivotTable - var olapTestFile = CreateOlapTestFile(nameof(AddValueField_OlapPivot_AutoCreatesDaxMeasure)); - using var batch = ExcelSession.BeginBatch(olapTestFile); - - // Act - Try to add Sales field as a Sum value field - // Use exact CubeField name format [TableName].[ColumnName] - // After implementation, should auto-create: [Regional Sales Total] = SUM('RegionalSalesTable'[Sales]) - // NOTE: Use unique name to avoid conflict with fixture's "Total Sales" measure on SalesTable - var result = _pivotCommands.AddValueField( - batch, - "DataModelPivot", - "[RegionalSalesTable].[Sales]", - AggregationFunction.Sum, - "Regional Sales Total"); - - // Assert - Should succeed with auto-created DAX measure - Assert.True(result.Success, $"AddValueField should auto-create DAX measure but failed: {result.ErrorMessage}"); - Assert.Equal("Regional Sales Total", result.FieldName); // Field name is the measure name - Assert.Equal(PivotFieldArea.Value, result.Area); - Assert.Equal("Regional Sales Total", result.CustomName); - - // Verify the DAX measure was created in Data Model - var dataModelCommands = new DataModelCommands(); - var measuresResult = dataModelCommands.ListMeasures(batch, "RegionalSalesTable"); - Assert.True(measuresResult.Success, $"Failed to list measures: {measuresResult.ErrorMessage}"); - - // Should contain either the auto-created measure or use the custom name - var hasMeasure = measuresResult.Measures.Any(m => - m.Name.Contains("Regional Sales Total", StringComparison.OrdinalIgnoreCase)); - Assert.True(hasMeasure, "Auto-created DAX measure should exist in Data Model"); - } - - /// - /// Test auto-creation of DAX measure with Count aggregation function. - /// Verifies that different aggregation functions generate correct DAX formulas. - /// - [Fact] - [Trait("Speed", "Medium")] - [Trait("Category", "OLAP")] - public void AddValueField_OlapPivot_AutoCreatesDaxMeasureWithCount() - { - // Arrange - var olapTestFile = CreateOlapTestFile(nameof(AddValueField_OlapPivot_AutoCreatesDaxMeasureWithCount)); - using var batch = ExcelSession.BeginBatch(olapTestFile); - - // Act - Add Quarter field with Count aggregation - // Use exact CubeField name format [TableName].[ColumnName] - // Should auto-create: [Number of Quarters] = COUNT('RegionalSalesTable'[Quarter]) - var result = _pivotCommands.AddValueField( - batch, - "DataModelPivot", - "[RegionalSalesTable].[Quarter]", - AggregationFunction.Count, - "Number of Quarters"); - - // Assert - Assert.True(result.Success, $"AddValueField with Count should auto-create DAX measure but failed: {result.ErrorMessage}"); - Assert.Equal("Number of Quarters", result.FieldName); // Field name is the measure name - Assert.Equal(PivotFieldArea.Value, result.Area); - Assert.Equal(AggregationFunction.Count, result.Function); - - // Verify the DAX measure was created with COUNT function - var dataModelCommands = new DataModelCommands(); - var measuresResult = dataModelCommands.ListMeasures(batch, "RegionalSalesTable"); - Assert.True(measuresResult.Success, $"Failed to list measures: {measuresResult.ErrorMessage}"); - var hasCountMeasure = measuresResult.Measures.Any(m => - m.Name.Contains("Quarter", StringComparison.OrdinalIgnoreCase) && - (m.FormulaPreview?.Contains("COUNT", StringComparison.OrdinalIgnoreCase) ?? false)); - Assert.True(hasCountMeasure, "Auto-created COUNT measure should exist in Data Model"); - } - - /// - /// Test adding a pre-existing measure to PivotTable values area. - /// This is the core scenario from the issue: user has a measure in Data Model and wants to add it to PivotTable. - /// Measure formats: "[Measures].[Name]", "Name", or CubeField name - /// - [Fact] - [Trait("Speed", "Medium")] - [Trait("Category", "OLAP")] - public void AddValueField_OlapPivot_AddsPreExistingMeasure() - { - // Arrange - Create OLAP test file and add a measure first - var olapTestFile = CreateOlapTestFile(nameof(AddValueField_OlapPivot_AddsPreExistingMeasure)); - using var batch = ExcelSession.BeginBatch(olapTestFile); - - // First, create a measure in the Data Model (not in PivotTable yet) - var dataModelCommands = new DataModelCommands(); - dataModelCommands.CreateMeasure( - batch, - "RegionalSalesTable", - "Total ACR", - "SUM('RegionalSalesTable'[Sales])", - null); // CreateMeasure throws on error - - // Refresh PivotTable to pick up the new measure in CubeFields - _pivotCommands.Refresh(batch, "DataModelPivot", null); - - // Act - Add the pre-existing measure to PivotTable values area - // Should detect it's an existing measure and just set Orientation = xlDataField - var result = _pivotCommands.AddValueField( - batch, - "DataModelPivot", - "Total ACR", // Can use measure name directly - AggregationFunction.Sum, // Ignored for pre-existing measures - null); - - // Assert - Should succeed without creating a new measure - Assert.True(result.Success, $"AddValueField should add existing measure but failed: {result.ErrorMessage}"); - Assert.Equal("Total ACR", result.FieldName); - Assert.Equal(PivotFieldArea.Value, result.Area); - - // Verify only ONE measure with this name exists (not duplicated) - var measuresResult = dataModelCommands.ListMeasures(batch, "RegionalSalesTable"); - Assert.True(measuresResult.Success, $"Failed to list measures: {measuresResult.ErrorMessage}"); - var measureCount = measuresResult.Measures.Count(m => m.Name == "Total ACR"); - Assert.Equal(1, measureCount); // Should still be 1, not 2 - } - - /// - /// Test adding pre-existing measure using [Measures].[Name] format. - /// This format is commonly used in OLAP/MDX contexts and should be supported. - /// - [Fact] - [Trait("Speed", "Medium")] - [Trait("Category", "OLAP")] - public void AddValueField_OlapPivot_AddsPreExistingMeasureWithMeasuresPrefix() - { - // Arrange - Create measure first - var olapTestFile = CreateOlapTestFile(nameof(AddValueField_OlapPivot_AddsPreExistingMeasureWithMeasuresPrefix)); - using var batch = ExcelSession.BeginBatch(olapTestFile); - - var dataModelCommands = new DataModelCommands(); - dataModelCommands.CreateMeasure( - batch, - "RegionalSalesTable", - "Revenue Total", - "SUM('RegionalSalesTable'[Sales])", - null); // CreateMeasure throws on error - - _pivotCommands.Refresh(batch, "DataModelPivot", null); - - // Act - Use [Measures].[Name] format (common in OLAP contexts) - var result = _pivotCommands.AddValueField( - batch, - "DataModelPivot", - "[Measures].[Revenue Total]", // MDX-style format - AggregationFunction.Sum, - null); - - // Assert - Assert.True(result.Success, $"Should handle [Measures].[Name] format but failed: {result.ErrorMessage}"); - Assert.Equal("Revenue Total", result.FieldName); - Assert.Equal(PivotFieldArea.Value, result.Area); - } - - - /// - /// Helper to get the OLAP test file path from shared DataModelPivotTableFixture. - /// Uses shared fixture instead of creating new one each time (massive performance improvement). - /// - private string CreateOlapTestFile(string _) - { - // Use the shared fixture from [Collection("DataModel")] - created ONCE for all test classes - // This is initialized ONCE per test run, not per test method or per test class - return _olapFixture.TestFilePath; - } - - /// - /// Test UPDATE: Change aggregation function for existing OLAP value field. - /// Verifies that SetFieldFunction modifies the DAX measure formula in Data Model. - /// - [Fact] - [Trait("Speed", "Medium")] - [Trait("Category", "OLAP")] - public void SetFieldFunction_OlapPivot_UpdatesDaxMeasureFormula() - { - // Arrange - Create measure with SUM first - var olapTestFile = CreateOlapTestFile(nameof(SetFieldFunction_OlapPivot_UpdatesDaxMeasureFormula)); - using var batch = ExcelSession.BeginBatch(olapTestFile); - - // Use exact CubeField name format [TableName].[ColumnName] - var addResult = _pivotCommands.AddValueField( - batch, - "DataModelPivot", - "[RegionalSalesTable].[Sales]", - AggregationFunction.Sum, - "Sales Measure"); - Assert.True(addResult.Success, $"Setup failed: {addResult.ErrorMessage}"); - - // Act - Change from SUM to COUNT - // After the measure is created, reference it by its measure name or [Measures].[Name] - var updateResult = _pivotCommands.SetFieldFunction( - batch, - "DataModelPivot", - "[Measures].[Sales Measure]", - AggregationFunction.Count); - - // Assert - Operation succeeded - Assert.True(updateResult.Success, $"Update failed: {updateResult.ErrorMessage}"); - Assert.Contains("Sales Measure", updateResult.FieldName); - Assert.Equal(AggregationFunction.Count, updateResult.Function); - - // Verify the DAX measure formula changed in Data Model - var dataModelCommands = new DataModelCommands(); - var measuresResult = dataModelCommands.ListMeasures(batch, "RegionalSalesTable"); - Assert.True(measuresResult.Success, $"Failed to list measures: {measuresResult.ErrorMessage}"); - - var measure = measuresResult.Measures.FirstOrDefault(m => m.Name == "Sales Measure"); - Assert.NotNull(measure); - Assert.Contains("COUNT", measure.FormulaPreview, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("SUM", measure.FormulaPreview, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Test UPDATE: Change number format for existing OLAP value field. - /// Verifies that SetFieldFormat modifies the measure's format in Data Model. - /// - [Fact] - [Trait("Speed", "Medium")] - [Trait("Category", "OLAP")] - public void SetFieldFormat_OlapPivot_UpdatesMeasureFormat() - { - // Arrange - Create measure first - var olapTestFile = CreateOlapTestFile(nameof(SetFieldFormat_OlapPivot_UpdatesMeasureFormat)); - using var batch = ExcelSession.BeginBatch(olapTestFile); - - // Use exact CubeField name format [TableName].[ColumnName] - var addResult = _pivotCommands.AddValueField( - batch, - "DataModelPivot", - "[RegionalSalesTable].[Sales]", - AggregationFunction.Sum, - "Sales Total"); - Assert.True(addResult.Success, $"Setup failed: {addResult.ErrorMessage}"); - - // Act - Set a simple format that Excel preserves exactly - // After the measure is created, reference it by [Measures].[Name] - // Use "0%" which is locale-independent - var updateResult = _pivotCommands.SetFieldFormat( - batch, - "DataModelPivot", - "[Measures].[Sales Total]", - "0%"); - - // Assert - Operation succeeded - Assert.True(updateResult.Success, $"Update failed: {updateResult.ErrorMessage}"); - Assert.Contains("Sales Total", updateResult.FieldName); - Assert.Equal("0%", updateResult.NumberFormat); - } - - /// - /// Test UPDATE: Format a PRE-EXISTING measure (not created in same test). - /// This covers the bug scenario where SetFieldFormat failed for measures - /// created via datamodel tool, which exist in CubeFields but not - /// in the same code path as AddValueField-created measures. - /// - /// BUG REGRESSION TEST: The old SetFieldFormat searched model.ModelMeasures - /// but pre-existing measures may not be there in the expected format. - /// The fix uses CubeField.PivotFields[1].NumberFormat directly. - /// - [Fact] - [Trait("Speed", "Medium")] - [Trait("Category", "OLAP")] - public void SetFieldFormat_PreExistingMeasure_FormatsSuccessfully() - { - // Arrange - Use fixture which has pre-existing "ACR" measure on DisambiguationTable - // This measure was created via dataModelCommands.CreateMeasure() during fixture init - // NOT via AddValueField in this test - simulating real-world scenario - var olapTestFile = CreateOlapTestFile(nameof(SetFieldFormat_PreExistingMeasure_FormatsSuccessfully)); - using var batch = ExcelSession.BeginBatch(olapTestFile); - - // First, add the pre-existing ACR measure to the DisambiguationTest PivotTable - // The measure exists in Data Model but needs to be added to PivotTable's Values area - var addResult = _pivotCommands.AddValueField( - batch, - "DisambiguationTest", - "[Measures].[ACR]", // Pre-existing measure from fixture - AggregationFunction.Sum, // Ignored for existing measures - null); // Keep existing name - Assert.True(addResult.Success, $"AddValueField failed: {addResult.ErrorMessage}"); - - // Act - Format the pre-existing measure (this was the bug scenario) - // Use "0%" format which is locale-independent - var formatResult = _pivotCommands.SetFieldFormat( - batch, - "DisambiguationTest", - "[Measures].[ACR]", - "0%"); - - // Assert - Operation succeeded (was failing with "Measure not found in Data Model") - Assert.True(formatResult.Success, $"SetFieldFormat failed: {formatResult.ErrorMessage}"); - Assert.Contains("ACR", formatResult.FieldName); - Assert.Equal("0%", formatResult.NumberFormat); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.Operations.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.Operations.cs deleted file mode 100644 index 252f0e96..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.Operations.cs +++ /dev/null @@ -1,185 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.PivotTable; - -/// -/// Tests for PivotTable operations (List, GetInfo, Delete, Refresh, GetData) -/// Optimized: Single batch per test, no SaveAsync() unless testing persistence -/// -public partial class PivotTableCommandsTests -{ - /// - [Fact] - [Trait("Speed", "Medium")] - public void List_EmptyWorkbook_ReturnsEmptyList() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(List_EmptyWorkbook_ReturnsEmptyList)); - - // Act - using var batch = ExcelSession.BeginBatch(testFile); - var result = _pivotCommands.List(batch); - - // Assert - Assert.True(result.Success, $"List failed: {result.ErrorMessage}"); - Assert.NotNull(result.PivotTables); - Assert.Empty(result.PivotTables); - } - /// - - [Fact] - [Trait("Speed", "Medium")] - public void List_WithPivotTable_ReturnsPivotTableInfo() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(List_WithPivotTable_ReturnsPivotTableInfo)); - - using var batch = ExcelSession.BeginBatch(testFile); - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F1", "TestPivot"); - Assert.True(createResult.Success); - - // Act - No save needed, same batch - var result = _pivotCommands.List(batch); - - // Assert - Assert.True(result.Success, $"List failed: {result.ErrorMessage}"); - Assert.NotEmpty(result.PivotTables); - var pivot = Assert.Single(result.PivotTables); - Assert.Equal("TestPivot", pivot.Name); - Assert.Equal("SalesData", pivot.SheetName); - } - /// - - [Fact] - [Trait("Speed", "Medium")] - public void GetInfo_ExistingPivotTable_ReturnsCompleteInfo() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(GetInfo_ExistingPivotTable_ReturnsCompleteInfo)); - - using var batch = ExcelSession.BeginBatch(testFile); - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F1", "TestPivot"); - Assert.True(createResult.Success); - - // Act - No save needed - var result = _pivotCommands.Read(batch, "TestPivot"); - - // Assert - Assert.True(result.Success, $"GetInfo failed: {result.ErrorMessage}"); - Assert.Equal("TestPivot", result.PivotTable.Name); - Assert.NotEmpty(result.Fields); - Assert.Equal(4, result.Fields.Count); // Region, Product, Sales, Date - } - /// - - [Fact] - [Trait("Speed", "Medium")] - public void GetInfo_NonExistentPivotTable_ReturnsError() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(GetInfo_NonExistentPivotTable_ReturnsError)); - - // Act & Assert - expects exception when pivot table not found - using var batch = ExcelSession.BeginBatch(testFile); - var ex = Assert.Throws(() => _pivotCommands.Read(batch, "NonExistent")); - Assert.Contains("not found", ex.Message, StringComparison.OrdinalIgnoreCase); - } - /// - - [Fact] - [Trait("Speed", "Medium")] - public void Delete_ExistingPivotTable_RemovesPivotTable() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(Delete_ExistingPivotTable_RemovesPivotTable)); - - using var batch = ExcelSession.BeginBatch(testFile); - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F1", "TestPivot"); - Assert.True(createResult.Success); - - // Act - Delete in same batch - var deleteResult = _pivotCommands.Delete(batch, "TestPivot"); - - // Assert - Assert.True(deleteResult.Success, $"Delete failed: {deleteResult.ErrorMessage}"); - - // Verify pivot no longer exists - var listResult = _pivotCommands.List(batch); - Assert.True(listResult.Success); - Assert.Empty(listResult.PivotTables); - } - /// - - [Fact] - [Trait("Speed", "Medium")] - public void Delete_NonExistentPivotTable_ReturnsError() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(Delete_NonExistentPivotTable_ReturnsError)); - - // Act & Assert - expects exception when pivot table not found - using var batch = ExcelSession.BeginBatch(testFile); - var ex = Assert.Throws(() => _pivotCommands.Delete(batch, "NonExistent")); - Assert.Contains("not found", ex.Message, StringComparison.OrdinalIgnoreCase); - } - /// - - [Fact] - [Trait("Speed", "Medium")] - public void Refresh_ExistingPivotTable_UpdatesData() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(Refresh_ExistingPivotTable_UpdatesData)); - - using var batch = ExcelSession.BeginBatch(testFile); - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F1", "TestPivot"); - Assert.True(createResult.Success); - - // Act - Refresh in same batch - var result = _pivotCommands.Refresh(batch, "TestPivot"); - - // Assert - Assert.True(result.Success, $"Refresh failed: {result.ErrorMessage}"); - Assert.Equal("TestPivot", result.PivotTableName); - Assert.True(result.RefreshTime <= DateTime.Now); - Assert.True(result.SourceRecordCount >= 0); - } - /// - - [Fact] - [Trait("Speed", "Medium")] - public void GetData_ExistingPivotTable_ReturnsData() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(GetData_ExistingPivotTable_ReturnsData)); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create pivot with row field to generate data - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F1", "TestPivot"); - Assert.True(createResult.Success); - - // Add Region to row area - var addRowResult = _pivotCommands.AddRowField(batch, "TestPivot", "Region"); - Assert.True(addRowResult.Success); - - // Act - GetData in same batch - var result = _pivotCommands.GetData(batch, "TestPivot"); - - // Assert - Assert.True(result.Success, $"GetData failed: {result.ErrorMessage}"); - Assert.Equal("TestPivot", result.PivotTableName); - Assert.NotNull(result.Values); - Assert.NotEmpty(result.Values); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.Slicers.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.Slicers.cs deleted file mode 100644 index 359813da..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.Slicers.cs +++ /dev/null @@ -1,490 +0,0 @@ -using Microsoft.Extensions.Logging; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.PivotTable; - -public partial class PivotTableCommandsTests -{ - /// - /// Tests creating a slicer for a PivotTable field. - /// - [Fact] - [Trait("Speed", "Medium")] - public void CreateSlicer_ValidField_CreatesSlicerSuccessfully() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(CreateSlicer_ValidField_CreatesSlicerSuccessfully)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - // Create PivotTable - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F2", "SlicerTest"); - Assert.True(createResult.Success, $"Failed to create PivotTable: {createResult.ErrorMessage}"); - - // Add Region to Row area - var addFieldResult = _pivotCommands.AddRowField(batch, "SlicerTest", "Region"); - Assert.True(addFieldResult.Success, $"Failed to add Region field: {addFieldResult.ErrorMessage}"); - - // Act - Create slicer for Region field - var slicerResult = _pivotCommands.CreateSlicer( - batch, - pivotTableName: "SlicerTest", - fieldName: "Region", - slicerName: "RegionSlicer", - destinationSheet: "SalesData", - position: "I2"); - - // Assert - Assert.True(slicerResult.Success, $"CreateSlicer failed: {slicerResult.ErrorMessage}"); - Assert.Equal("RegionSlicer", slicerResult.Name); - Assert.Equal("Region", slicerResult.FieldName); - Assert.Equal("SalesData", slicerResult.SheetName); - Assert.NotNull(slicerResult.AvailableItems); - Assert.Contains("North", slicerResult.AvailableItems); - Assert.Contains("South", slicerResult.AvailableItems); - Assert.NotNull(slicerResult.WorkflowHint); - } - - /// - /// Tests listing slicers in a workbook with no filter. - /// - [Fact] - [Trait("Speed", "Medium")] - public void ListSlicers_WithSlicers_ReturnsAllSlicers() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(ListSlicers_WithSlicers_ReturnsAllSlicers)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - // Create PivotTable - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F2", "ListSlicersTest"); - Assert.True(createResult.Success, $"Failed to create PivotTable: {createResult.ErrorMessage}"); - - // Add fields - _pivotCommands.AddRowField(batch, "ListSlicersTest", "Region"); - _pivotCommands.AddRowField(batch, "ListSlicersTest", "Product"); - - // Create two slicers - var slicer1Result = _pivotCommands.CreateSlicer( - batch, "ListSlicersTest", "Region", "RegionSlicer1", "SalesData", "I2"); - Assert.True(slicer1Result.Success, $"Failed to create slicer 1: {slicer1Result.ErrorMessage}"); - - var slicer2Result = _pivotCommands.CreateSlicer( - batch, "ListSlicersTest", "Product", "ProductSlicer1", "SalesData", "I10"); - Assert.True(slicer2Result.Success, $"Failed to create slicer 2: {slicer2Result.ErrorMessage}"); - - // Act - var listResult = _pivotCommands.ListSlicers(batch); - - // Assert - Assert.True(listResult.Success, $"ListSlicers failed: {listResult.ErrorMessage}"); - Assert.NotNull(listResult.Slicers); - Assert.True(listResult.Slicers.Count >= 2, $"Expected at least 2 slicers, got {listResult.Slicers.Count}"); - Assert.Contains(listResult.Slicers, s => s.Name == "RegionSlicer1"); - Assert.Contains(listResult.Slicers, s => s.Name == "ProductSlicer1"); - } - - /// - /// Tests listing slicers filtered by PivotTable name. - /// - [Fact] - [Trait("Speed", "Medium")] - public void ListSlicers_FilterByPivotTable_ReturnsConnectedSlicersOnly() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(ListSlicers_FilterByPivotTable_ReturnsConnectedSlicersOnly)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - // Create PivotTable - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F2", "FilterSlicersTest"); - Assert.True(createResult.Success, $"Failed to create PivotTable: {createResult.ErrorMessage}"); - - // Add Region field and create slicer - _pivotCommands.AddRowField(batch, "FilterSlicersTest", "Region"); - var slicerResult = _pivotCommands.CreateSlicer( - batch, "FilterSlicersTest", "Region", "FilterRegionSlicer", "SalesData", "I2"); - Assert.True(slicerResult.Success, $"Failed to create slicer: {slicerResult.ErrorMessage}"); - - // Act - var listResult = _pivotCommands.ListSlicers(batch, pivotTableName: "FilterSlicersTest"); - - // Assert - Assert.True(listResult.Success, $"ListSlicers failed: {listResult.ErrorMessage}"); - Assert.NotNull(listResult.Slicers); - Assert.Single(listResult.Slicers); - Assert.Equal("FilterRegionSlicer", listResult.Slicers[0].Name); - } - - /// - /// Tests setting slicer selection to specific items. - /// - [Fact] - [Trait("Speed", "Medium")] - public void SetSlicerSelection_SpecificItems_SelectsOnlyThoseItems() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(SetSlicerSelection_SpecificItems_SelectsOnlyThoseItems)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - // Create PivotTable with Region field - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F2", "SelectionTest"); - Assert.True(createResult.Success, $"Failed to create PivotTable: {createResult.ErrorMessage}"); - - _pivotCommands.AddRowField(batch, "SelectionTest", "Region"); - - // Create slicer - var slicerResult = _pivotCommands.CreateSlicer( - batch, "SelectionTest", "Region", "SelectionSlicer", "SalesData", "I2"); - Assert.True(slicerResult.Success, $"Failed to create slicer: {slicerResult.ErrorMessage}"); - - // Act - Select only "North" - var selectionResult = _pivotCommands.SetSlicerSelection( - batch, "SelectionSlicer", new List { "North" }, clearFirst: true); - - // Assert - Assert.True(selectionResult.Success, $"SetSlicerSelection failed: {selectionResult.ErrorMessage}"); - Assert.NotNull(selectionResult.SelectedItems); - Assert.Single(selectionResult.SelectedItems); - Assert.Contains("North", selectionResult.SelectedItems); - Assert.DoesNotContain("South", selectionResult.SelectedItems); - Assert.NotNull(selectionResult.WorkflowHint); - } - - /// - /// Tests clearing slicer selection (selecting all items). - /// - [Fact] - [Trait("Speed", "Medium")] - public void SetSlicerSelection_EmptyList_ClearsFilterSelectsAll() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(SetSlicerSelection_EmptyList_ClearsFilterSelectsAll)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - // Create PivotTable - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F2", "ClearFilterTest"); - Assert.True(createResult.Success, $"Failed to create PivotTable: {createResult.ErrorMessage}"); - - _pivotCommands.AddRowField(batch, "ClearFilterTest", "Region"); - - // Create slicer - var slicerResult = _pivotCommands.CreateSlicer( - batch, "ClearFilterTest", "Region", "ClearFilterSlicer", "SalesData", "I2"); - Assert.True(slicerResult.Success, $"Failed to create slicer: {slicerResult.ErrorMessage}"); - - // First, filter to just "North" - _pivotCommands.SetSlicerSelection(batch, "ClearFilterSlicer", new List { "North" }); - - // Act - Clear filter by passing empty list - var clearResult = _pivotCommands.SetSlicerSelection( - batch, "ClearFilterSlicer", new List()); - - // Assert - Assert.True(clearResult.Success, $"SetSlicerSelection (clear) failed: {clearResult.ErrorMessage}"); - Assert.NotNull(clearResult.SelectedItems); - Assert.True(clearResult.SelectedItems.Count >= 2, "Expected all items to be selected after clear"); - Assert.Contains("North", clearResult.SelectedItems); - Assert.Contains("South", clearResult.SelectedItems); - Assert.Contains("cleared", clearResult.WorkflowHint, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Tests deleting a slicer from the workbook. - /// - [Fact] - [Trait("Speed", "Medium")] - public void DeleteSlicer_ExistingSlicer_RemovesSuccessfully() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(DeleteSlicer_ExistingSlicer_RemovesSuccessfully)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - // Create PivotTable - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F2", "DeleteSlicerTest"); - Assert.True(createResult.Success, $"Failed to create PivotTable: {createResult.ErrorMessage}"); - - _pivotCommands.AddRowField(batch, "DeleteSlicerTest", "Region"); - - // Create slicer - var slicerResult = _pivotCommands.CreateSlicer( - batch, "DeleteSlicerTest", "Region", "SlicerToDelete", "SalesData", "I2"); - Assert.True(slicerResult.Success, $"Failed to create slicer: {slicerResult.ErrorMessage}"); - - // Verify slicer exists - var listBeforeResult = _pivotCommands.ListSlicers(batch); - Assert.Contains(listBeforeResult.Slicers, s => s.Name == "SlicerToDelete"); - - // Act - var deleteResult = _pivotCommands.DeleteSlicer(batch, "SlicerToDelete"); - - // Assert - Assert.True(deleteResult.Success, $"DeleteSlicer failed: {deleteResult.ErrorMessage}"); - - // Verify slicer is gone - var listAfterResult = _pivotCommands.ListSlicers(batch); - Assert.DoesNotContain(listAfterResult.Slicers, s => s.Name == "SlicerToDelete"); - } - - /// - /// Tests deleting a non-existent slicer returns error. - /// - [Fact] - [Trait("Speed", "Medium")] - public void DeleteSlicer_NonExistentSlicer_ReturnsError() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(DeleteSlicer_NonExistentSlicer_ReturnsError)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - // Create PivotTable (no slicer) - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F2", "NoSlicerTest"); - Assert.True(createResult.Success, $"Failed to create PivotTable: {createResult.ErrorMessage}"); - - // Act - var deleteResult = _pivotCommands.DeleteSlicer(batch, "NonExistentSlicer"); - - // Assert - Assert.False(deleteResult.Success); - Assert.Contains("not found", deleteResult.ErrorMessage, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Tests setting slicer selection for non-existent slicer returns error. - /// - [Fact] - [Trait("Speed", "Medium")] - public void SetSlicerSelection_NonExistentSlicer_ReturnsError() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(SetSlicerSelection_NonExistentSlicer_ReturnsError)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - // Create PivotTable (no slicer) - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F2", "NoSlicerTest2"); - Assert.True(createResult.Success, $"Failed to create PivotTable: {createResult.ErrorMessage}"); - - // Act - var selectionResult = _pivotCommands.SetSlicerSelection( - batch, "NonExistentSlicer", new List { "North" }); - - // Assert - Assert.False(selectionResult.Success); - Assert.Contains("not found", selectionResult.ErrorMessage, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Tests listing slicers when workbook has no slicers. - /// - [Fact] - [Trait("Speed", "Medium")] - public void ListSlicers_NoSlicers_ReturnsEmptyList() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(ListSlicers_NoSlicers_ReturnsEmptyList)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - // Create PivotTable without any slicers - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F2", "NoSlicerPivot"); - Assert.True(createResult.Success, $"Failed to create PivotTable: {createResult.ErrorMessage}"); - - // Act - var listResult = _pivotCommands.ListSlicers(batch); - - // Assert - Assert.True(listResult.Success, $"ListSlicers failed: {listResult.ErrorMessage}"); - Assert.NotNull(listResult.Slicers); - Assert.Empty(listResult.Slicers); - } - - /// - /// Tests that slicer shows connected PivotTables. - /// - [Fact] - [Trait("Speed", "Medium")] - public void CreateSlicer_ShowsConnectedPivotTable() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(CreateSlicer_ShowsConnectedPivotTable)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - // Create PivotTable - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F2", "ConnectedPivot"); - Assert.True(createResult.Success, $"Failed to create PivotTable: {createResult.ErrorMessage}"); - - _pivotCommands.AddRowField(batch, "ConnectedPivot", "Region"); - - // Act - Create slicer - var slicerResult = _pivotCommands.CreateSlicer( - batch, "ConnectedPivot", "Region", "ConnectedSlicer", "SalesData", "I2"); - - // Assert - Assert.True(slicerResult.Success, $"CreateSlicer failed: {slicerResult.ErrorMessage}"); - Assert.NotNull(slicerResult.ConnectedPivotTables); - Assert.Contains("ConnectedPivot", slicerResult.ConnectedPivotTables); - } - - /// - /// Tests that slicer Position is returned as a valid cell reference. - /// This test catches bugs where Position is empty due to incorrect COM API usage. - /// Bug context: TopLeftCell is on Slicer.Shape, not Slicer directly. - /// - [Fact] - [Trait("Speed", "Medium")] - public void CreateSlicer_ReturnsValidPosition() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(CreateSlicer_ReturnsValidPosition)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - // Create PivotTable - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F2", "PositionTestPivot"); - Assert.True(createResult.Success, $"Failed to create PivotTable: {createResult.ErrorMessage}"); - _pivotCommands.AddRowField(batch, "PositionTestPivot", "Region"); - - // Act - Create slicer at I2 - var slicerResult = _pivotCommands.CreateSlicer( - batch, "PositionTestPivot", "Region", "PositionTestSlicer", "SalesData", "I2"); - - // Assert - Position must be a valid cell reference, not empty - Assert.True(slicerResult.Success, $"CreateSlicer failed: {slicerResult.ErrorMessage}"); - Assert.False(string.IsNullOrEmpty(slicerResult.Position), - "Slicer Position should not be empty - verify Shape.TopLeftCell API is used correctly"); - Assert.Matches(@"^[A-Z]+\d+$", slicerResult.Position); // e.g., "I2", "AA10" - } - - /// - /// Tests that ListSlicers returns valid Position for each slicer. - /// - [Fact] - [Trait("Speed", "Medium")] - public void ListSlicers_ReturnsValidPositionForEachSlicer() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(ListSlicers_ReturnsValidPositionForEachSlicer)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - // Create PivotTable with two slicers - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F2", "ListPosTestPivot"); - Assert.True(createResult.Success, $"Failed to create PivotTable: {createResult.ErrorMessage}"); - _pivotCommands.AddRowField(batch, "ListPosTestPivot", "Region"); - _pivotCommands.AddRowField(batch, "ListPosTestPivot", "Product"); - - _pivotCommands.CreateSlicer(batch, "ListPosTestPivot", "Region", "ListPosSlicer1", "SalesData", "I2"); - _pivotCommands.CreateSlicer(batch, "ListPosTestPivot", "Product", "ListPosSlicer2", "SalesData", "K2"); - - // Act - var listResult = _pivotCommands.ListSlicers(batch); - - // Assert - All slicers should have valid positions - Assert.True(listResult.Success, $"ListSlicers failed: {listResult.ErrorMessage}"); - foreach (var slicer in listResult.Slicers) - { - Assert.False(string.IsNullOrEmpty(slicer.Position), - $"Slicer '{slicer.Name}' has empty Position - verify Shape.TopLeftCell API"); - } - } - - /// - /// Tests that FieldName is returned correctly (not "Unknown"). - /// This test catches bugs where SourceName property access fails silently. - /// - [Fact] - [Trait("Speed", "Medium")] - public void CreateSlicer_ReturnsCorrectFieldName() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(CreateSlicer_ReturnsCorrectFieldName)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - // Create PivotTable - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F2", "FieldNameTestPivot"); - Assert.True(createResult.Success, $"Failed to create PivotTable: {createResult.ErrorMessage}"); - _pivotCommands.AddRowField(batch, "FieldNameTestPivot", "Region"); - - // Act - Create slicer for "Region" field - var slicerResult = _pivotCommands.CreateSlicer( - batch, "FieldNameTestPivot", "Region", "FieldNameTestSlicer", "SalesData", "I2"); - - // Assert - FieldName must match the field name, not be "Unknown" - Assert.True(slicerResult.Success, $"CreateSlicer failed: {slicerResult.ErrorMessage}"); - Assert.NotEqual("Unknown", slicerResult.FieldName); - Assert.Equal("Region", slicerResult.FieldName); - } - - /// - /// Tests that ConnectedPivotTables is returned correctly (not empty). - /// This test catches bugs where PivotTables collection access fails silently. - /// - [Fact] - [Trait("Speed", "Medium")] - public void ListSlicers_ReturnsCorrectConnectedPivotTables() - { - // Arrange - var testFile = CreateTestFileWithData(nameof(ListSlicers_ReturnsCorrectConnectedPivotTables)); - - var logger = _loggerFactory.CreateLogger(); - using var batch = new ExcelBatch(new[] { testFile }, logger); - - // Create PivotTable - var createResult = _pivotCommands.CreateFromRange( - batch, "SalesData", "A1:D6", "SalesData", "F2", "ConnPivotTestPivot"); - Assert.True(createResult.Success, $"Failed to create PivotTable: {createResult.ErrorMessage}"); - _pivotCommands.AddRowField(batch, "ConnPivotTestPivot", "Region"); - _pivotCommands.CreateSlicer(batch, "ConnPivotTestPivot", "Region", "ConnPivotTestSlicer", "SalesData", "I2"); - - // Act - var listResult = _pivotCommands.ListSlicers(batch); - - // Assert - ConnectedPivotTables must contain our PivotTable - Assert.True(listResult.Success, $"ListSlicers failed: {listResult.ErrorMessage}"); - var slicer = listResult.Slicers.FirstOrDefault(s => s.Name == "ConnPivotTestSlicer"); - Assert.NotNull(slicer); - Assert.NotNull(slicer.ConnectedPivotTables); - Assert.NotEmpty(slicer.ConnectedPivotTables); - Assert.Contains("ConnPivotTestPivot", slicer.ConnectedPivotTables); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.cs deleted file mode 100644 index 26f35111..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableCommandsTests.cs +++ /dev/null @@ -1,222 +0,0 @@ -using Microsoft.Extensions.Logging; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands.PivotTable; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; -using Xunit.Abstractions; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.PivotTable; - -/// -/// Integration tests for PivotTable commands. -/// Uses PivotTableTestsFixture which creates ONE data file per test class (~5-10s setup). -/// Uses DataModelPivotTableFixture for OLAP tests (shared across ALL test classes via collection fixture). -/// Fixture initialization IS the test for data preparation. -/// Each test gets its own batch for isolation. -/// -[Collection("DataModel")] -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("RequiresExcel", "true")] -[Trait("Feature", "PivotTables")] -public partial class PivotTableCommandsTests : IClassFixture -{ - private readonly PivotTableCommands _pivotCommands; - private readonly PivotTableTestsFixture _fixture; - private readonly DataModelPivotTableFixture _olapFixture; - private readonly string _pivotFile; - private readonly PivotTableCreationResult _creationResult; - private readonly ITestOutputHelper _output; - private readonly ILoggerFactory _loggerFactory; - - /// - /// Initializes a new instance of the class. - /// - public PivotTableCommandsTests(PivotTableTestsFixture fixture, DataModelPivotTableFixture olapFixture, ITestOutputHelper output) - { - _pivotCommands = new PivotTableCommands(); - _fixture = fixture; - _olapFixture = olapFixture; - _pivotFile = fixture.TestFilePath; - _creationResult = fixture.CreationResult; - _output = output; - _loggerFactory = LoggerFactory.Create(builder => builder - .AddXUnit(output) - .SetMinimumLevel(LogLevel.Trace)); - } - - /// - /// Helper to create unique test file with sales data for pivot table tests. - /// Used when tests need unique files for specific scenarios. - /// - private string CreateTestFileWithData(string testName) - { - var testFile = _fixture.CreateTestFile(testName); - - using var batch = ExcelSession.BeginBatch(testFile); - - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - sheet.Name = "SalesData"; - - sheet.Range["A1"].Value2 = "Region"; - sheet.Range["B1"].Value2 = "Product"; - sheet.Range["C1"].Value2 = "Sales"; - sheet.Range["D1"].Value2 = "Date"; - - sheet.Range["A2"].Value2 = "North"; - sheet.Range["B2"].Value2 = "Widget"; - sheet.Range["C2"].Value2 = 100; - sheet.Range["D2"].Value2 = new DateTime(2025, 1, 15); - - sheet.Range["A3"].Value2 = "North"; - sheet.Range["B3"].Value2 = "Widget"; - sheet.Range["C3"].Value2 = 150; - sheet.Range["D3"].Value2 = new DateTime(2025, 1, 20); - - sheet.Range["A4"].Value2 = "South"; - sheet.Range["B4"].Value2 = "Gadget"; - sheet.Range["C4"].Value2 = 200; - sheet.Range["D4"].Value2 = new DateTime(2025, 2, 10); - - sheet.Range["A5"].Value2 = "North"; - sheet.Range["B5"].Value2 = "Gadget"; - sheet.Range["C5"].Value2 = 75; - sheet.Range["D5"].Value2 = new DateTime(2025, 2, 15); - - sheet.Range["A6"].Value2 = "South"; - sheet.Range["B6"].Value2 = "Widget"; - sheet.Range["C6"].Value2 = 125; - sheet.Range["D6"].Value2 = new DateTime(2025, 3, 5); - - // CRITICAL: Format Date column with date format so PivotTable recognizes dates - // Without this, dates are stored as serial numbers (45672) and Excel won't group them - sheet.Range["D2:D6"].NumberFormat = "m/d/yyyy"; - - return 0; - }); - - batch.Save(); - - return testFile; - } - - /// - /// Explicit test that validates the fixture creation results. - /// This makes the data preparation test visible in test results and validates: - /// - SessionManager.CreateSessionForNewFile() - /// - Sales data creation - /// - Batch.Save() persistence - /// - [Fact] - [Trait("Speed", "Fast")] - public void DataPreparation_ViaFixture_CreatesSalesData() - { - // Assert the fixture creation succeeded - Assert.True(_creationResult.Success, - $"Data preparation failed during fixture initialization: {_creationResult.ErrorMessage}"); - - Assert.True(_creationResult.FileCreated, "File creation failed"); - Assert.Equal(5, _creationResult.DataRowsCreated); - Assert.True(_creationResult.CreationTimeSeconds > 0); - - // This test appears in test results as proof that creation was tested - Console.WriteLine($"? Data prepared successfully in {_creationResult.CreationTimeSeconds:F1}s"); - } - - /// - /// Tests that sales data persists correctly after file close/reopen. - /// Validates that SaveAsync() properly persisted the data. - /// - [Fact] - [Trait("Speed", "Medium")] - public void DataPreparation_Persists_AfterReopenFile() - { - // Close and reopen to verify persistence (new batch = new session) - using var batch = ExcelSession.BeginBatch(_pivotFile); - - // Verify data persisted by reading range - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets["SalesData"]; - - // Verify headers - Assert.Equal("Region", sheet.Range["A1"].Value2?.ToString()); - Assert.Equal("Product", sheet.Range["B1"].Value2?.ToString()); - Assert.Equal("Sales", sheet.Range["C1"].Value2?.ToString()); - Assert.Equal("Date", sheet.Range["D1"].Value2?.ToString()); - - // Verify first data row - Assert.Equal("North", sheet.Range["A2"].Value2?.ToString()); - Assert.Equal("Widget", sheet.Range["B2"].Value2?.ToString()); - Assert.Equal(100.0, Convert.ToDouble(sheet.Range["C2"].Value2)); - - return 0; - }); - - // This proves data creation + save worked correctly - } -} - -/// -/// Custom logger provider that writes to xUnit output -/// -internal sealed class TestLoggerProvider : ILoggerProvider -{ - private readonly ITestOutputHelper _output; - - public TestLoggerProvider(ITestOutputHelper output) - { - _output = output; - } - - public ILogger CreateLogger(string categoryName) - { - return new TestLogger(_output, categoryName); - } - - public void Dispose() - { - } -} - -/// -/// Custom logger that writes to xUnit output -/// -internal sealed class TestLogger : ILogger -{ - private readonly ITestOutputHelper _output; - private readonly string _categoryName; - - public TestLogger(ITestOutputHelper output, string categoryName) - { - _output = output; - _categoryName = categoryName; - } - - public IDisposable? BeginScope(TState state) where TState : notnull - { - return null; - } - - public bool IsEnabled(LogLevel logLevel) - { - return true; - } - - public void Log( - LogLevel logLevel, - EventId eventId, - TState state, - Exception? exception, - Func formatter) - { - var message = formatter(state, exception); - _output.WriteLine($"[{logLevel}] {_categoryName}: {message}"); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableOlapDisambiguationTests.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableOlapDisambiguationTests.cs deleted file mode 100644 index 1b1e562c..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/PivotTable/PivotTableOlapDisambiguationTests.cs +++ /dev/null @@ -1,256 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands.PivotTable; -using Sbroenne.ExcelMcp.Core.Models; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; -using Xunit.Abstractions; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.PivotTable; - -/// -/// Regression tests for OLAP PivotTable measure disambiguation issues. -/// -/// BUG REPORT: When adding DAX measures to OLAP PivotTables, the server incorrectly -/// matches table columns with similar names instead of the actual DAX measure. -/// -/// Issues being tested: -/// 1. AddValueField with "[Measures].[ACR]" matches "[DisambiguationTable].[ACRTypeKey]" instead of measure -/// 2. Partial matching causes wrong field to be added when names overlap -/// 3. CubeFieldType should be used to distinguish measures from hierarchies -/// -/// These tests use the shared PivotTableRealisticFixture which creates: -/// - DisambiguationTable with columns "ACRTypeKey", "DiscountCode" -/// - DAX measures "ACR", "Discount" that could be confused with columns -/// - DisambiguationTest PivotTable connected to the Data Model -/// -[Collection("DataModel")] -[Trait("Category", "Integration")] -[Trait("Feature", "PivotTables")] -[Trait("RequiresExcel", "true")] -public class PivotTableOlapDisambiguationTests -{ - private readonly DataModelPivotTableFixture _fixture; - private readonly PivotTableCommands _pivotCommands; - private readonly ITestOutputHelper _output; - - public PivotTableOlapDisambiguationTests(DataModelPivotTableFixture fixture, ITestOutputHelper output) - { - _fixture = fixture; - _pivotCommands = new PivotTableCommands(); - _output = output; - } - - /// - /// REGRESSION TEST: AddValueField with [Measures].[MeasureName] should add the DAX measure, - /// not a table column with a similar name. - /// - /// BUG: When calling AddValueField with fieldName="[Measures].[ACR]", the current implementation - /// uses Contains() matching which matches "[DisambiguationTable].[ACRTypeKey]" because it contains "ACR". - /// - /// EXPECTED: Only the DAX measure "ACR" should be matched, not table columns. - /// - [Fact] - [Trait("Speed", "Medium")] - [Trait("Category", "Regression")] - public void AddValueField_MeasuresPrefix_ShouldNotMatchTableColumn() - { - // Arrange - Use the shared fixture file - Assert.True(_fixture.CreationResult.Success, $"Fixture creation failed: {_fixture.CreationResult.ErrorMessage}"); - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - - // Act - Try to add the DAX measure using [Measures].[Name] syntax - var result = _pivotCommands.AddValueField( - batch, - "DisambiguationTest", - "[Measures].[ACR]", // Should match DAX measure, NOT [DisambiguationTable].[ACRTypeKey] - AggregationFunction.Sum, - null); - - // Assert - The operation should succeed - Assert.True(result.Success, $"AddValueField failed: {result.ErrorMessage}"); - - // CRITICAL: The result should show the MEASURE was added, not a table column - // If the bug exists, FieldName would contain "ACRTypeKey" instead of "ACR" - _output.WriteLine($"FieldName: {result.FieldName}"); - _output.WriteLine($"CustomName: {result.CustomName}"); - _output.WriteLine($"Area: {result.Area}"); - - Assert.Equal("ACR", result.FieldName); - Assert.DoesNotContain("ACRTypeKey", result.CustomName ?? "", StringComparison.OrdinalIgnoreCase); - - // The area should be Value (xlDataField) - Assert.Equal(PivotFieldArea.Value, result.Area); - } - - /// - /// REGRESSION TEST: AddValueField with exact measure name should add the DAX measure, - /// not a table column with a similar name. - /// - /// BUG: When calling AddValueField with fieldName="Discount", the current implementation - /// iterates through CubeFields and uses Contains() which matches "DiscountCode" column first. - /// - /// EXPECTED: Exact measure name matching should find the measure, not a column. - /// - [Fact] - [Trait("Speed", "Medium")] - [Trait("Category", "Regression")] - public void AddValueField_ExactMeasureName_ShouldNotMatchTableColumn() - { - // Arrange - Assert.True(_fixture.CreationResult.Success, $"Fixture creation failed: {_fixture.CreationResult.ErrorMessage}"); - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - - // Act - Try to add the DAX measure using exact name (no [Measures]. prefix) - var result = _pivotCommands.AddValueField( - batch, - "DisambiguationTest", - "Discount", // Should match DAX measure, NOT [DisambiguationTable].[DiscountCode] - AggregationFunction.Sum, - null); - - // Assert - Assert.True(result.Success, $"AddValueField failed: {result.ErrorMessage}"); - - _output.WriteLine($"FieldName: {result.FieldName}"); - _output.WriteLine($"CustomName: {result.CustomName}"); - - // CRITICAL: The result should show the MEASURE was added - Assert.Equal("Discount", result.FieldName); - Assert.DoesNotContain("DiscountCode", result.CustomName ?? "", StringComparison.OrdinalIgnoreCase); - Assert.Equal(PivotFieldArea.Value, result.Area); - } - - /// - /// Test that CubeFieldType property can distinguish measures from hierarchies. - /// This verifies we can use the COM API to properly identify measure CubeFields. - /// - [Fact] - [Trait("Speed", "Medium")] - public void CubeFields_CanIdentifyMeasuresByCubeFieldType() - { - // Arrange - Assert.True(_fixture.CreationResult.Success, $"Fixture creation failed: {_fixture.CreationResult.ErrorMessage}"); - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - - // Act - Enumerate CubeFields and check their types - var measureFields = new List<(string Name, int CubeFieldType)>(); - var hierarchyFields = new List<(string Name, int CubeFieldType)>(); - - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets["DisambiguationPivot"]; - dynamic pivotTable = sheet.PivotTables("DisambiguationTest"); - dynamic cubeFields = pivotTable.CubeFields; - - for (int i = 1; i <= cubeFields.Count; i++) - { - dynamic? cf = null; - try - { - cf = cubeFields[i]; - string name = cf.Name?.ToString() ?? ""; - int cubeFieldType = Convert.ToInt32(cf.CubeFieldType); - - // xlMeasure = 2, xlHierarchy = 1 - if (cubeFieldType == 2) // xlMeasure - { - measureFields.Add((name, cubeFieldType)); - } - else if (cubeFieldType == 1) // xlHierarchy - { - hierarchyFields.Add((name, cubeFieldType)); - } - } - finally - { - if (cf != null) - ComUtilities.Release(ref cf!); - } - } - - ComUtilities.Release(ref cubeFields!); - return 0; - }); - - // Assert - We should find measures with CubeFieldType = 2 - _output.WriteLine($"Found {measureFields.Count} measures:"); - foreach (var (name, type) in measureFields) - { - _output.WriteLine($" - {name} (type={type})"); - } - - _output.WriteLine($"Found {hierarchyFields.Count} hierarchies (showing first 10):"); - foreach (var (name, type) in hierarchyFields.Take(10)) - { - _output.WriteLine($" - {name} (type={type})"); - } - - Assert.NotEmpty(measureFields); - - // Our created measures should be in the measure list - Assert.Contains(measureFields, m => m.Name.Contains("ACR", StringComparison.OrdinalIgnoreCase)); - Assert.Contains(measureFields, m => m.Name.Contains("Discount", StringComparison.OrdinalIgnoreCase)); - - // Table columns (ACRTypeKey, DiscountCode) should NOT be measures - Assert.DoesNotContain(measureFields, m => m.Name.Contains("ACRTypeKey", StringComparison.OrdinalIgnoreCase)); - Assert.DoesNotContain(measureFields, m => m.Name.Contains("DiscountCode", StringComparison.OrdinalIgnoreCase)); - } - - /// - /// After adding a measure to Values area, ListFields should show it - /// with Area = Value, not Area = Hidden. - /// - [Fact] - [Trait("Speed", "Medium")] - [Trait("Category", "Regression")] - public void ListFields_AfterAddValueField_ShouldShowMeasureInValueArea() - { - // Arrange - Assert.True(_fixture.CreationResult.Success, $"Fixture creation failed: {_fixture.CreationResult.ErrorMessage}"); - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - - // First, add the measure to values area (use unambiguous measure name) - var addResult = _pivotCommands.AddValueField( - batch, - "DisambiguationTest", - "[Measures].[ACR]", - AggregationFunction.Sum, - null); - - // Even if add fails due to bug, let's check ListFields - _output.WriteLine($"AddValueField result: Success={addResult.Success}, FieldName={addResult.FieldName}"); - - // Act - List all fields - var listResult = _pivotCommands.ListFields(batch, "DisambiguationTest"); - - // Assert - Assert.True(listResult.Success, $"ListFields failed: {listResult.ErrorMessage}"); - - _output.WriteLine($"Fields in PivotTable:"); - foreach (var field in listResult.Fields) - { - _output.WriteLine($" - {field.Name}: Area={field.Area}"); - } - - // Find ACR in the field list (could be measure or incorrectly matched column) - var acrFields = listResult.Fields.Where(f => - f.Name.Contains("ACR", StringComparison.OrdinalIgnoreCase)).ToList(); - - Assert.NotEmpty(acrFields); - - // If fix is applied: ACR measure should be in Value area - // If bug exists: ACRTypeKey column might be matched instead - var measureField = acrFields.FirstOrDefault(f => - f.Name.Contains("[Measures]", StringComparison.OrdinalIgnoreCase)); - - if (measureField != null) - { - Assert.Equal(PivotFieldArea.Value, measureField.Area); - } - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryBackgroundQueryRegressionTests.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryBackgroundQueryRegressionTests.cs deleted file mode 100644 index 00b58429..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryBackgroundQueryRegressionTests.cs +++ /dev/null @@ -1,124 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands; -using Sbroenne.ExcelMcp.Core.Models; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.PowerQuery; - -/// -/// Regression tests for the BackgroundQuery CPU spin bug in Power Query refresh. -/// -/// Root cause: PowerQueryCommands.RefreshWorkbookConnection (in Helpers.cs) forced -/// the OLEDBConnection.BackgroundQuery to true before calling connection.Refresh(). -/// With BackgroundQuery = true, connection.Refresh() returned immediately (async), -/// leaving the STA thread to poll connection.Refreshing with Thread.Sleep(200). -/// OleMessageFilter caused Thread.Sleep to return immediately on every COM event -/// (via MsgWaitForMultipleObjectsEx), creating a 100% CPU spin for the full -/// duration of the refresh — seconds to minutes per query. -/// -/// Fix: Force BackgroundQuery = false before calling connection.Refresh(). -/// Refresh() then blocks synchronously; connection.Refreshing is false on return; -/// the polling loop exits in 0 iterations. Zero spin. -/// -/// This class tests the Connection.Refresh() path (Strategy 2 in RefreshConnectionByQueryName), -/// which is used for Data Model queries. Worksheet queries use QueryTable.Refresh() (Strategy 1). -/// -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("Feature", "PowerQuery")] -[Trait("RequiresExcel", "true")] -[Trait("Speed", "Medium")] -public class PowerQueryBackgroundQueryRegressionTests : IClassFixture -{ - private readonly DataModelCommands _dataModelCommands; - private readonly PowerQueryCommands _powerQueryCommands; - private readonly TempDirectoryFixture _fixture; - - public PowerQueryBackgroundQueryRegressionTests(TempDirectoryFixture fixture) - { - _dataModelCommands = new DataModelCommands(); - _powerQueryCommands = new PowerQueryCommands(_dataModelCommands); - _fixture = fixture; - } - - /// - /// Regression test: A Power Query loaded to the Data Model must refresh successfully - /// via the Connection.Refresh() path without causing a CPU spin. - /// - /// This tests Strategy 2 of RefreshConnectionByQueryName — the path where - /// RefreshWorkbookConnection() was calling BackgroundQuery = true and spinning. - /// - [Fact] - public void Refresh_DataModelQuery_CompletesWithoutCpuSpin() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - var queryName = "DM_BGQ_Regression_" + Guid.NewGuid().ToString("N")[..8]; - - const string mCode = @"let - Source = #table( - {""ID"", ""Name"", ""Value""}, - { - {1, ""Alpha"", 100}, - {2, ""Beta"", 200}, - {3, ""Gamma"", 300} - } - ) -in - Source"; - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create the query and load to Data Model (uses Connection.Refresh() path internally) - _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToDataModel); - - // Act — explicitly refresh via PowerQueryCommands.Refresh() which calls - // RefreshConnectionByQueryName → Strategy 2 → RefreshWorkbookConnection. - // Before the fix: this would spin at ~100% CPU for the duration of the refresh. - // After the fix: BackgroundQuery is forced to false, connection.Refresh() blocks - // synchronously, the polling loop exits immediately (0 iterations). - var result = _powerQueryCommands.Refresh(batch, queryName, TimeSpan.FromMinutes(2)); - - // Assert - Assert.True(result.Success, $"Refresh failed: {result.ErrorMessage}"); - Assert.Equal(queryName, result.QueryName); - // Not loaded to worksheet (Data Model only) - Assert.True( - result.IsConnectionOnly || string.IsNullOrEmpty(result.LoadedToSheet), - "Data Model query should not be loaded to a worksheet."); - } - - /// - /// Regression test: A worksheet Power Query must also refresh without a CPU spin. - /// This tests Strategy 1 of RefreshConnectionByQueryName (QueryTable.Refresh path). - /// - [Fact] - public void Refresh_WorksheetQuery_CompletesSuccessfully() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - var queryName = "WS_BGQ_Regression_" + Guid.NewGuid().ToString("N")[..8]; - - const string mCode = @"let - Source = #table( - {""ID"", ""Name""}, - {{1, ""Alpha""}, {2, ""Beta""}} - ) -in - Source"; - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create the query loaded to worksheet (uses QueryTable.Refresh() path) - _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToTable); - - // Act — refresh via Strategy 1 path (QueryTable.Refresh) - var result = _powerQueryCommands.Refresh(batch, queryName, TimeSpan.FromMinutes(2)); - - // Assert - Assert.True(result.Success, $"Refresh failed: {result.ErrorMessage}"); - Assert.False(string.IsNullOrEmpty(result.LoadedToSheet), - "Worksheet query should have a loaded sheet."); - } -} diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.DataModelLoading.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.DataModelLoading.cs deleted file mode 100644 index 96f5ec63..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.DataModelLoading.cs +++ /dev/null @@ -1,1061 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands; -using Sbroenne.ExcelMcp.Core.Models; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.PowerQuery; - -/// -/// Tests for Power Query operations with Data Model loading. -/// -/// These tests validate that: -/// - LoadToDataModel settings are preserved after Update -/// - No duplicate tables are created in the Data Model -/// - Refresh operations work correctly with Data Model loading -/// -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("Feature", "PowerQuery")] -[Trait("Feature", "DataModel")] -[Trait("RequiresExcel", "true")] -[Trait("Speed", "Medium")] -[Collection("Sequential")] -public class PowerQueryDataModelLoadingTests : IClassFixture -{ - private readonly PowerQueryCommands _powerQueryCommands; - private readonly DataModelCommands _dataModelCommands; - private readonly TempDirectoryFixture _fixture; - - public PowerQueryDataModelLoadingTests(TempDirectoryFixture fixture) - { - _dataModelCommands = new DataModelCommands(); - _powerQueryCommands = new PowerQueryCommands(_dataModelCommands); - _fixture = fixture; - } - - #region LoadToDataModel Preservation Tests - - /// - /// Regression test: PowerQuery create with LoadToDataModel must register the table - /// in the Data Model (via connection.Refresh()). Before the fix, Connections.Add2() - /// was called without Refresh(), so the table appeared to succeed but was never - /// actually loaded into the Data Model. - /// - [Fact] - public async Task Create_LoadToDataModel_TableAppearsInDataModel() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_LoadDM_" + Guid.NewGuid().ToString("N")[..8]; - - var mCode = @"let - Source = #table( - {""ID"", ""Name""}, - { - {1, ""Alpha""}, - {2, ""Beta""} - } - ) -in - Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Act - _ = _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToDataModel); - - // Assert - The table must appear in the Data Model after load - var tables = await _dataModelCommands.ListTables(batch); - Assert.True(tables.Success, $"ListTables failed: {tables.ErrorMessage}"); - var matchingTables = tables.Tables.Where(t => t.Name == queryName).ToList(); - Assert.Single(matchingTables); // Table must be registered in Data Model - } - - /// - /// CRITICAL TEST: Validates that Update preserves LoadToDataModel settings - /// and doesn't create duplicate tables in the Data Model. - /// - /// Scenario: - /// 1. Create PowerQuery and load to Data Model - /// 2. Verify one table exists in Data Model - /// 3. Update the PowerQuery M code - /// 4. Verify still only ONE table in Data Model (no duplicates) - /// 5. Verify LoadToDataModel setting is preserved - /// - [Fact] - public async Task Update_LoadedToDataModel_PreservesSettingsAndNoDuplicateTables() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_DataModel_" + Guid.NewGuid().ToString("N")[..8]; - - var initialMCode = @"let - Source = #table( - {""ID"", ""Name"", ""Amount""}, - { - {1, ""Alpha"", 100}, - {2, ""Beta"", 200} - } - ) -in - Source"; - - var updatedMCode = @"let - Source = #table( - {""ID"", ""Name"", ""Amount""}, - { - {1, ""Alpha"", 150}, - {2, ""Beta"", 250}, - {3, ""Gamma"", 350} - } - ) -in - Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // STEP 1: Create PowerQuery and load to Data Model - _ = _powerQueryCommands.Create(batch, queryName, initialMCode, PowerQueryLoadMode.LoadToDataModel); - - // STEP 2: Verify initial load configuration - var loadConfigBefore = _powerQueryCommands.GetLoadConfig(batch, queryName); - Assert.True(loadConfigBefore.Success, $"GetLoadConfig before failed: {loadConfigBefore.ErrorMessage}"); - Assert.Equal(PowerQueryLoadMode.LoadToDataModel, loadConfigBefore.LoadMode); - - // STEP 3: Verify ONE table exists in Data Model - var tablesBefore = await _dataModelCommands.ListTables(batch); - Assert.True(tablesBefore.Success, $"ListTables before failed: {tablesBefore.ErrorMessage}"); - var queryTablesBefore = tablesBefore.Tables.Where(t => t.Name == queryName).ToList(); - Assert.Single(queryTablesBefore); - - // STEP 4: Update the M code (this triggers auto-refresh) - _ = _powerQueryCommands.Update(batch, queryName, updatedMCode); - - // STEP 5: Verify load configuration is PRESERVED after Update - var loadConfigAfter = _powerQueryCommands.GetLoadConfig(batch, queryName); - Assert.True(loadConfigAfter.Success, $"GetLoadConfig after failed: {loadConfigAfter.ErrorMessage}"); - Assert.Equal(PowerQueryLoadMode.LoadToDataModel, loadConfigAfter.LoadMode); - - // STEP 6: Verify still ONLY ONE table in Data Model (no duplicates) - var tablesAfter = await _dataModelCommands.ListTables(batch); - Assert.True(tablesAfter.Success, $"ListTables after failed: {tablesAfter.ErrorMessage}"); - var queryTablesAfter = tablesAfter.Tables.Where(t => t.Name == queryName).ToList(); - Assert.Single(queryTablesAfter); - - // STEP 7: Verify M code was actually updated - var viewResult = _powerQueryCommands.View(batch, queryName); - Assert.True(viewResult.Success, $"View failed: {viewResult.ErrorMessage}"); - Assert.Contains("Gamma", viewResult.MCode); - Assert.Contains("350", viewResult.MCode); - } - - /// - /// Tests that multiple sequential updates don't create duplicate Data Model tables. - /// - [Fact] - public async Task Update_MultipleUpdatesToDataModel_NoDuplicateTables() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_MultiUpdate_" + Guid.NewGuid().ToString("N")[..8]; - - var mCodeV1 = @"let Source = #table({""Val""}, {{1}}) in Source"; - var mCodeV2 = @"let Source = #table({""Val""}, {{2}}) in Source"; - var mCodeV3 = @"let Source = #table({""Val""}, {{3}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create with LoadToDataModel - _ = _powerQueryCommands.Create(batch, queryName, mCodeV1, PowerQueryLoadMode.LoadToDataModel); - - // Update #1 - _ = _powerQueryCommands.Update(batch, queryName, mCodeV2); - - // Update #2 - _ = _powerQueryCommands.Update(batch, queryName, mCodeV3); - - // Verify still LoadToDataModel - var loadConfig = _powerQueryCommands.GetLoadConfig(batch, queryName); - Assert.True(loadConfig.Success, $"GetLoadConfig failed: {loadConfig.ErrorMessage}"); - Assert.Equal(PowerQueryLoadMode.LoadToDataModel, loadConfig.LoadMode); - - // Verify only ONE table in Data Model - var tables = await _dataModelCommands.ListTables(batch); - Assert.True(tables.Success, $"ListTables failed: {tables.ErrorMessage}"); - var queryTables = tables.Tables.Where(t => t.Name == queryName).ToList(); - Assert.Single(queryTables); - } - - /// - /// Tests that Refresh preserves LoadToDataModel settings. - /// - [Fact] - public async Task Refresh_LoadedToDataModel_PreservesSettings() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_RefreshDM_" + Guid.NewGuid().ToString("N")[..8]; - - var mCode = @"let Source = #table({""Val""}, {{42}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create with LoadToDataModel - _ = _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToDataModel); - - // Verify initial state - var loadConfigBefore = _powerQueryCommands.GetLoadConfig(batch, queryName); - Assert.Equal(PowerQueryLoadMode.LoadToDataModel, loadConfigBefore.LoadMode); - - // Refresh - var refreshResult = _powerQueryCommands.Refresh(batch, queryName, TimeSpan.FromMinutes(5)); - Assert.True(refreshResult.Success, $"Refresh failed: {refreshResult.ErrorMessage}"); - - // Verify LoadToDataModel preserved - var loadConfigAfter = _powerQueryCommands.GetLoadConfig(batch, queryName); - Assert.True(loadConfigAfter.Success, $"GetLoadConfig after failed: {loadConfigAfter.ErrorMessage}"); - Assert.Equal(PowerQueryLoadMode.LoadToDataModel, loadConfigAfter.LoadMode); - - // Verify still only one table - var tables = await _dataModelCommands.ListTables(batch); - Assert.True(tables.Success, $"ListTables failed: {tables.ErrorMessage}"); - var queryTables = tables.Tables.Where(t => t.Name == queryName).ToList(); - Assert.Single(queryTables); - } - - /// - /// Tests that ConnectionOnly load mode is correctly detected. - /// - [Fact] - public void GetLoadConfig_ConnectionOnly_ReturnsConnectionOnlyMode() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_ConnOnly_" + Guid.NewGuid().ToString("N")[..8]; - - var mCode = @"let Source = #table({""Val""}, {{1}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create with ConnectionOnly (no loading) - _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.ConnectionOnly); - - // Act - var loadConfig = _powerQueryCommands.GetLoadConfig(batch, queryName); - - // Assert - Assert.True(loadConfig.Success, $"GetLoadConfig failed: {loadConfig.ErrorMessage}"); - Assert.Equal(PowerQueryLoadMode.ConnectionOnly, loadConfig.LoadMode); - Assert.True(string.IsNullOrEmpty(loadConfig.TargetSheet), "ConnectionOnly should not have a target sheet"); - } - - /// - /// Tests that LoadToTable mode is correctly detected. - /// - [Fact] - public void GetLoadConfig_LoadToTable_ReturnsLoadToTableMode() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_Table_" + Guid.NewGuid().ToString("N")[..8]; - var sheetName = "TableSheet"; - - var mCode = @"let Source = #table({""Val""}, {{42}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create with LoadToTable - _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToTable, sheetName); - - // Act - var loadConfig = _powerQueryCommands.GetLoadConfig(batch, queryName); - - // Assert - Assert.True(loadConfig.Success, $"GetLoadConfig failed: {loadConfig.ErrorMessage}"); - Assert.Equal(PowerQueryLoadMode.LoadToTable, loadConfig.LoadMode); - Assert.Equal(sheetName, loadConfig.TargetSheet); - } - - #endregion - - #region LoadToBoth Preservation Tests - - /// - /// Tests that Update preserves LoadToBoth settings. - /// - [Fact] - public async Task Update_LoadedToBoth_PreservesSettings() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_Both_" + Guid.NewGuid().ToString("N")[..8]; - - var initialMCode = @"let Source = #table({""A""}, {{1}}) in Source"; - var updatedMCode = @"let Source = #table({""A""}, {{2}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create with LoadToBoth - _ = _powerQueryCommands.Create(batch, queryName, initialMCode, PowerQueryLoadMode.LoadToBoth); - - // Verify initial state - var loadConfigBefore = _powerQueryCommands.GetLoadConfig(batch, queryName); - Assert.True(loadConfigBefore.Success, $"GetLoadConfig failed: {loadConfigBefore.ErrorMessage}"); - Assert.True(loadConfigBefore.HasConnection, "Expected HasConnection=true after Create with LoadToBoth"); - Assert.Equal(PowerQueryLoadMode.LoadToBoth, loadConfigBefore.LoadMode); - - // Update - _ = _powerQueryCommands.Update(batch, queryName, updatedMCode); - - // Verify LoadToBoth preserved - var loadConfigAfter = _powerQueryCommands.GetLoadConfig(batch, queryName); - Assert.True(loadConfigAfter.Success, $"GetLoadConfig after failed: {loadConfigAfter.ErrorMessage}"); - Assert.Equal(PowerQueryLoadMode.LoadToBoth, loadConfigAfter.LoadMode); - - // Verify table exists in Data Model - var tables = await _dataModelCommands.ListTables(batch); - Assert.True(tables.Success, $"ListTables failed: {tables.ErrorMessage}"); - var queryTables = tables.Tables.Where(t => t.Name == queryName).ToList(); - Assert.Single(queryTables); - } - - /// - /// Tests that Refresh preserves LoadToBoth settings. - /// - [Fact] - public async Task Refresh_LoadedToBoth_PreservesSettings() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_RefreshBoth_" + Guid.NewGuid().ToString("N")[..8]; - - var mCode = @"let Source = #table({""Val""}, {{99}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create with LoadToBoth - _ = _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToBoth); - - // Verify initial state - var loadConfigBefore = _powerQueryCommands.GetLoadConfig(batch, queryName); - Assert.Equal(PowerQueryLoadMode.LoadToBoth, loadConfigBefore.LoadMode); - - // Refresh - var refreshResult = _powerQueryCommands.Refresh(batch, queryName, TimeSpan.FromMinutes(5)); - Assert.True(refreshResult.Success, $"Refresh failed: {refreshResult.ErrorMessage}"); - - // Verify LoadToBoth preserved - var loadConfigAfter = _powerQueryCommands.GetLoadConfig(batch, queryName); - Assert.True(loadConfigAfter.Success, $"GetLoadConfig after failed: {loadConfigAfter.ErrorMessage}"); - Assert.Equal(PowerQueryLoadMode.LoadToBoth, loadConfigAfter.LoadMode); - - // Verify table still in Data Model - var tables = await _dataModelCommands.ListTables(batch); - Assert.True(tables.Success, $"ListTables failed: {tables.ErrorMessage}"); - var queryTables = tables.Tables.Where(t => t.Name == queryName).ToList(); - Assert.Single(queryTables); - } - - #endregion - - #region Multiple Queries Tests - - /// - /// Tests GetLoadConfig correctly identifies different load modes for multiple queries. - /// - [Fact] - public async Task GetLoadConfig_MultipleQueriesDifferentModes_ReturnsCorrectModeForEach() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var suffix = Guid.NewGuid().ToString("N")[..6]; - var queryConnOnly = "PQ_ConnOnly_" + suffix; - var queryTable = "PQ_Table_" + suffix; - var queryDataModel = "PQ_DataModel_" + suffix; - var queryBoth = "PQ_Both_" + suffix; - - var mCode = @"let Source = #table({""A""}, {{1}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create queries with different load modes - _ = _powerQueryCommands.Create(batch, queryConnOnly, mCode, PowerQueryLoadMode.ConnectionOnly); - _ = _powerQueryCommands.Create(batch, queryTable, mCode, PowerQueryLoadMode.LoadToTable, "Sheet1"); - _ = _powerQueryCommands.Create(batch, queryDataModel, mCode, PowerQueryLoadMode.LoadToDataModel); - _ = _powerQueryCommands.Create(batch, queryBoth, mCode, PowerQueryLoadMode.LoadToBoth, "Sheet2"); - - // Act & Assert - each query should report its correct load mode - var configConnOnly = _powerQueryCommands.GetLoadConfig(batch, queryConnOnly); - Assert.True(configConnOnly.Success); - Assert.Equal(PowerQueryLoadMode.ConnectionOnly, configConnOnly.LoadMode); - - var configTable = _powerQueryCommands.GetLoadConfig(batch, queryTable); - Assert.True(configTable.Success); - Assert.Equal(PowerQueryLoadMode.LoadToTable, configTable.LoadMode); - - var configDataModel = _powerQueryCommands.GetLoadConfig(batch, queryDataModel); - Assert.True(configDataModel.Success); - Assert.Equal(PowerQueryLoadMode.LoadToDataModel, configDataModel.LoadMode); - - var configBoth = _powerQueryCommands.GetLoadConfig(batch, queryBoth); - Assert.True(configBoth.Success); - Assert.Equal(PowerQueryLoadMode.LoadToBoth, configBoth.LoadMode); - - // Verify Data Model has expected tables - var tables = await _dataModelCommands.ListTables(batch); - Assert.True(tables.Success); - Assert.Contains(tables.Tables, t => t.Name == queryDataModel); - Assert.Contains(tables.Tables, t => t.Name == queryBoth); - Assert.DoesNotContain(tables.Tables, t => t.Name == queryConnOnly); - Assert.DoesNotContain(tables.Tables, t => t.Name == queryTable); - } - - #endregion - - #region Schema Change Tests (Column Structure Changes) - - /// - /// BUG INVESTIGATION TEST: Column structure changes on Data Model-connected queries. - /// - /// Bug Report: Update fails with 0x800A03EC when updating Power Query that: - /// 1. Is loaded to Data Model - /// 2. Has schema structure change (add/remove columns) - /// - /// This test validates the scenario where: - /// 1. Create query with 2 columns loaded to Data Model - /// 2. Update query to add a 3rd column - /// 3. Expected: Either succeeds OR throws meaningful exception - /// - [Fact] - public async Task Update_LoadedToDataModel_AddColumn_HandlesSchemaChange() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_SchemaChange_" + Guid.NewGuid().ToString("N")[..8]; - - // Initial M code with 2 columns - var twoColumnMCode = @"let - Source = #table( - {""ID"", ""Name""}, - { - {1, ""Alpha""}, - {2, ""Beta""} - } - ) -in - Source"; - - // Updated M code with 3 columns (ADDS ""Amount"" column) - var threeColumnMCode = @"let - Source = #table( - {""ID"", ""Name"", ""Amount""}, - { - {1, ""Alpha"", 100}, - {2, ""Beta"", 200}, - {3, ""Gamma"", 300} - } - ) -in - Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // STEP 1: Create PowerQuery and load to Data Model - _ = _powerQueryCommands.Create(batch, queryName, twoColumnMCode, PowerQueryLoadMode.LoadToDataModel); - - // STEP 2: Verify initial state - 1 table with 2 columns - var tablesBefore = await _dataModelCommands.ListTables(batch); - Assert.True(tablesBefore.Success, $"ListTables before failed: {tablesBefore.ErrorMessage}"); - Assert.Single(tablesBefore.Tables, t => t.Name == queryName); - - var tableBefore = await _dataModelCommands.ReadTable(batch, queryName); - Assert.True(tableBefore.Success, $"ReadTable before failed: {tableBefore.ErrorMessage}"); - Assert.Equal(2, tableBefore.Columns.Count); // ID, Name - - // STEP 3: Update the M code to ADD A COLUMN - // This is the bug scenario - schema change on Data Model-connected query - _ = _powerQueryCommands.Update(batch, queryName, threeColumnMCode); - - // STEP 4: Verify load configuration is PRESERVED - var loadConfigAfter = _powerQueryCommands.GetLoadConfig(batch, queryName); - Assert.True(loadConfigAfter.Success, $"GetLoadConfig after failed: {loadConfigAfter.ErrorMessage}"); - Assert.Equal(PowerQueryLoadMode.LoadToDataModel, loadConfigAfter.LoadMode); - - // STEP 5: Verify Data Model table now has 3 columns - var tableAfter = await _dataModelCommands.ReadTable(batch, queryName); - Assert.True(tableAfter.Success, $"ReadTable after failed: {tableAfter.ErrorMessage}"); - Assert.Equal(3, tableAfter.Columns.Count); // ID, Name, Amount - - // STEP 6: Verify M code was actually updated - var viewResult = _powerQueryCommands.View(batch, queryName); - Assert.True(viewResult.Success, $"View failed: {viewResult.ErrorMessage}"); - Assert.Contains("Amount", viewResult.MCode); - } - - /// - /// BUG INVESTIGATION TEST: Column removal on Data Model-connected queries. - /// - /// This test validates the scenario where: - /// 1. Create query with 3 columns loaded to Data Model - /// 2. Update query to remove a column (now 2 columns) - /// 3. Expected: Either succeeds OR throws meaningful exception - /// - [Fact] - public async Task Update_LoadedToDataModel_RemoveColumn_HandlesSchemaChange() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_RemoveCol_" + Guid.NewGuid().ToString("N")[..8]; - - // Initial M code with 3 columns - var threeColumnMCode = @"let - Source = #table( - {""ID"", ""Name"", ""Amount""}, - { - {1, ""Alpha"", 100}, - {2, ""Beta"", 200} - } - ) -in - Source"; - - // Updated M code with 2 columns (REMOVES ""Amount"" column) - var twoColumnMCode = @"let - Source = #table( - {""ID"", ""Name""}, - { - {1, ""Alpha""}, - {2, ""Beta""}, - {3, ""Gamma""} - } - ) -in - Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // STEP 1: Create PowerQuery and load to Data Model - _ = _powerQueryCommands.Create(batch, queryName, threeColumnMCode, PowerQueryLoadMode.LoadToDataModel); - - // STEP 2: Verify initial state - 1 table with 3 columns - var tableBefore = await _dataModelCommands.ReadTable(batch, queryName); - Assert.True(tableBefore.Success, $"ReadTable before failed: {tableBefore.ErrorMessage}"); - Assert.Equal(3, tableBefore.Columns.Count); // ID, Name, Amount - - // STEP 3: Update the M code to REMOVE A COLUMN - _ = _powerQueryCommands.Update(batch, queryName, twoColumnMCode); - - // STEP 4: Verify load configuration is PRESERVED - var loadConfigAfter = _powerQueryCommands.GetLoadConfig(batch, queryName); - Assert.True(loadConfigAfter.Success, $"GetLoadConfig after failed: {loadConfigAfter.ErrorMessage}"); - Assert.Equal(PowerQueryLoadMode.LoadToDataModel, loadConfigAfter.LoadMode); - - // STEP 5: Verify Data Model table now has 2 columns - var tableAfter = await _dataModelCommands.ReadTable(batch, queryName); - Assert.True(tableAfter.Success, $"ReadTable after failed: {tableAfter.ErrorMessage}"); - Assert.Equal(2, tableAfter.Columns.Count); // ID, Name - - // STEP 6: Verify M code was actually updated - var viewResult = _powerQueryCommands.View(batch, queryName); - Assert.True(viewResult.Success, $"View failed: {viewResult.ErrorMessage}"); - Assert.DoesNotContain("Amount", viewResult.MCode); - } - - /// - /// BUG INVESTIGATION TEST: Column type change on Data Model-connected queries. - /// - /// This test validates the scenario where: - /// 1. Create query with columns loaded to Data Model - /// 2. Update query to change column data type (e.g., number to text) - /// 3. Expected: Either succeeds OR throws meaningful exception - /// - [Fact] - public async Task Update_LoadedToDataModel_ChangeColumnType_HandlesSchemaChange() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_TypeChange_" + Guid.NewGuid().ToString("N")[..8]; - - // Initial M code with Amount as number - var numberTypeMCode = @"let - Source = #table( - {""ID"", ""Amount""}, - { - {1, 100}, - {2, 200} - } - ) -in - Source"; - - // Updated M code with Amount as text - var textTypeMCode = @"let - Source = #table( - {""ID"", ""Amount""}, - { - {1, ""One Hundred""}, - {2, ""Two Hundred""}, - {3, ""Three Hundred""} - } - ) -in - Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // STEP 1: Create PowerQuery and load to Data Model - _ = _powerQueryCommands.Create(batch, queryName, numberTypeMCode, PowerQueryLoadMode.LoadToDataModel); - - // STEP 2: Verify initial state - var tableBefore = await _dataModelCommands.ReadTable(batch, queryName); - Assert.True(tableBefore.Success, $"ReadTable before failed: {tableBefore.ErrorMessage}"); - Assert.Equal(2, tableBefore.Columns.Count); - - // STEP 3: Update the M code to CHANGE COLUMN TYPE - _ = _powerQueryCommands.Update(batch, queryName, textTypeMCode); - - // STEP 4: Verify load configuration is PRESERVED - var loadConfigAfter = _powerQueryCommands.GetLoadConfig(batch, queryName); - Assert.True(loadConfigAfter.Success, $"GetLoadConfig after failed: {loadConfigAfter.ErrorMessage}"); - Assert.Equal(PowerQueryLoadMode.LoadToDataModel, loadConfigAfter.LoadMode); - - // STEP 5: Verify M code was actually updated - var viewResult = _powerQueryCommands.View(batch, queryName); - Assert.True(viewResult.Success, $"View failed: {viewResult.ErrorMessage}"); - Assert.Contains("One Hundred", viewResult.MCode); - } - - /// - /// BUG INVESTIGATION TEST: Schema change when DAX measure references the table. - /// - /// Bug Report: Update fails with 0x800A03EC for Power Query AND 0x800AC472 for DAX measures - /// when updating Power Query that has DAX measures referencing it. - /// - /// This test validates the scenario where: - /// 1. Create query with columns loaded to Data Model - /// 2. Create DAX measure that references a column - /// 3. Update query to add a column (schema change) - /// 4. Expected: Either succeeds OR throws meaningful exception - /// - [Fact] - public async Task Update_LoadedToDataModel_WithDaxMeasure_AddColumn_HandlesSchemaChange() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_DaxMeasure_" + Guid.NewGuid().ToString("N")[..8]; - - // Initial M code with 2 columns - var twoColumnMCode = @"let - Source = #table( - {""ID"", ""Amount""}, - { - {1, 100}, - {2, 200}, - {3, 300} - } - ) -in - Source"; - - // Updated M code with 3 columns (ADDS ""Category"" column) - var threeColumnMCode = @"let - Source = #table( - {""ID"", ""Amount"", ""Category""}, - { - {1, 100, ""A""}, - {2, 200, ""B""}, - {3, 300, ""A""}, - {4, 400, ""C""} - } - ) -in - Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // STEP 1: Create PowerQuery and load to Data Model - _ = _powerQueryCommands.Create(batch, queryName, twoColumnMCode, PowerQueryLoadMode.LoadToDataModel); - - // STEP 2: Verify table exists in Data Model - var tableBefore = await _dataModelCommands.ReadTable(batch, queryName); - Assert.True(tableBefore.Success, $"ReadTable before failed: {tableBefore.ErrorMessage}"); - Assert.Equal(2, tableBefore.Columns.Count); // ID, Amount - - // STEP 3: Create DAX measure that references the Amount column - var measureName = "TotalAmount"; - var daxFormula = $"SUM('{queryName}'[Amount])"; - _ = _dataModelCommands.CreateMeasure(batch, queryName, measureName, daxFormula); // Throws on error - - // STEP 4: Verify measure exists - var measuresBefore = _dataModelCommands.ListMeasures(batch); - Assert.True(measuresBefore.Success, $"ListMeasures failed: {measuresBefore.ErrorMessage}"); - Assert.Contains(measuresBefore.Measures, m => m.Name == measureName); - - // STEP 5: Update the M code to ADD A COLUMN (this is the bug scenario) - // The DAX measure references Amount - adding Category should NOT break it - _ = _powerQueryCommands.Update(batch, queryName, threeColumnMCode); - - // STEP 6: Verify load configuration is PRESERVED - var loadConfigAfter = _powerQueryCommands.GetLoadConfig(batch, queryName); - Assert.True(loadConfigAfter.Success, $"GetLoadConfig after failed: {loadConfigAfter.ErrorMessage}"); - Assert.Equal(PowerQueryLoadMode.LoadToDataModel, loadConfigAfter.LoadMode); - - // STEP 7: Verify Data Model table now has 3 columns - var tableAfter = await _dataModelCommands.ReadTable(batch, queryName); - Assert.True(tableAfter.Success, $"ReadTable after failed: {tableAfter.ErrorMessage}"); - Assert.Equal(3, tableAfter.Columns.Count); // ID, Amount, Category - - // STEP 8: Verify DAX measure STILL EXISTS and is valid - var measuresAfter = _dataModelCommands.ListMeasures(batch); - Assert.True(measuresAfter.Success, $"ListMeasures after failed: {measuresAfter.ErrorMessage}"); - Assert.Contains(measuresAfter.Measures, m => m.Name == measureName); - - // STEP 9: Verify M code was actually updated - var viewResult = _powerQueryCommands.View(batch, queryName); - Assert.True(viewResult.Success, $"View failed: {viewResult.ErrorMessage}"); - Assert.Contains("Category", viewResult.MCode); - } - - /// - /// BUG INVESTIGATION TEST: Schema change removes column referenced by DAX measure. - /// - /// This is the MOST DANGEROUS scenario - removing a column that a DAX measure depends on. - /// - /// This test validates the scenario where: - /// 1. Create query with columns loaded to Data Model - /// 2. Create DAX measure that references a specific column - /// 3. Update query to REMOVE that column - /// 4. Expected: Should fail gracefully with meaningful error (DAX measure becomes invalid) - /// - [Fact] - public async Task Update_LoadedToDataModel_WithDaxMeasure_RemoveReferencedColumn_HandlesError() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_DaxBreak_" + Guid.NewGuid().ToString("N")[..8]; - - // Initial M code with Amount column - var withAmountMCode = @"let - Source = #table( - {""ID"", ""Name"", ""Amount""}, - { - {1, ""Alpha"", 100}, - {2, ""Beta"", 200} - } - ) -in - Source"; - - // Updated M code WITHOUT Amount column (REMOVES column that DAX measure references) - var withoutAmountMCode = @"let - Source = #table( - {""ID"", ""Name""}, - { - {1, ""Alpha""}, - {2, ""Beta""}, - {3, ""Gamma""} - } - ) -in - Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // STEP 1: Create PowerQuery and load to Data Model - _ = _powerQueryCommands.Create(batch, queryName, withAmountMCode, PowerQueryLoadMode.LoadToDataModel); - - // STEP 2: Verify table exists with Amount column - var tableBefore = await _dataModelCommands.ReadTable(batch, queryName); - Assert.True(tableBefore.Success, $"ReadTable before failed: {tableBefore.ErrorMessage}"); - Assert.Equal(3, tableBefore.Columns.Count); // ID, Name, Amount - - // STEP 3: Create DAX measure that references the Amount column - var measureName = "TotalAmount"; - var daxFormula = $"SUM('{queryName}'[Amount])"; - _ = _dataModelCommands.CreateMeasure(batch, queryName, measureName, daxFormula); // Throws on error - - // STEP 4: Update the M code to REMOVE the Amount column - // This should cause issues because the DAX measure references Amount - // The system should either: - // a) Fail gracefully with a meaningful error, OR - // b) Succeed but leave the DAX measure in an invalid state - _ = _powerQueryCommands.Update(batch, queryName, withoutAmountMCode); - - // STEP 5: Verify M code was updated (the update itself should succeed) - var viewResult = _powerQueryCommands.View(batch, queryName); - Assert.True(viewResult.Success, $"View failed: {viewResult.ErrorMessage}"); - Assert.DoesNotContain("Amount", viewResult.MCode); - - // STEP 6: Verify table no longer has Amount column - var tableAfter = await _dataModelCommands.ReadTable(batch, queryName); - Assert.True(tableAfter.Success, $"ReadTable after failed: {tableAfter.ErrorMessage}"); - Assert.Equal(2, tableAfter.Columns.Count); // ID, Name only - - // STEP 7: The DAX measure may still exist but reference an invalid column - // This is expected behavior - Excel/Power Pivot doesn't auto-delete measures - var measuresAfter = _dataModelCommands.ListMeasures(batch); - Assert.True(measuresAfter.Success, $"ListMeasures after failed: {measuresAfter.ErrorMessage}"); - // Measure may or may not still exist - either is acceptable - } - - /// - /// BUG INVESTIGATION TEST: Update DAX measure after schema change. - /// - /// Bug Report: DAX measure update fails with 0x800AC472 after Power Query update. - /// - /// This test validates the scenario where: - /// 1. Create query loaded to Data Model - /// 2. Create DAX measure - /// 3. Update Power Query (schema change) - /// 4. Update the DAX measure formula - /// 5. Expected: Either succeeds OR throws meaningful exception - /// - [Fact] - public async Task Update_DaxMeasure_AfterSchemaChange_HandlesUpdate() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_DaxUpdate_" + Guid.NewGuid().ToString("N")[..8]; - - // Initial M code - var initialMCode = @"let - Source = #table( - {""ID"", ""Amount""}, - { - {1, 100}, - {2, 200} - } - ) -in - Source"; - - // Updated M code with new column - var updatedMCode = @"let - Source = #table( - {""ID"", ""Amount"", ""Quantity""}, - { - {1, 100, 5}, - {2, 200, 10}, - {3, 300, 15} - } - ) -in - Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // STEP 1: Create PowerQuery and load to Data Model - _ = _powerQueryCommands.Create(batch, queryName, initialMCode, PowerQueryLoadMode.LoadToDataModel); - - // STEP 2: Create initial DAX measure - var measureName = "TotalAmount"; - var initialDaxFormula = $"SUM('{queryName}'[Amount])"; - _ = _dataModelCommands.CreateMeasure(batch, queryName, measureName, initialDaxFormula); // Throws on error - - // STEP 3: Update the M code (schema change - adds Quantity column) - _ = _powerQueryCommands.Update(batch, queryName, updatedMCode); - - // STEP 4: Verify schema change worked - var tableAfter = await _dataModelCommands.ReadTable(batch, queryName); - Assert.True(tableAfter.Success, $"ReadTable after schema change failed: {tableAfter.ErrorMessage}"); - Assert.Equal(3, tableAfter.Columns.Count); // ID, Amount, Quantity - - // STEP 5: Update the DAX measure to use the NEW column (this is the 0x800AC472 bug scenario) - var updatedDaxFormula = $"SUM('{queryName}'[Amount]) + SUM('{queryName}'[Quantity])"; - _ = _dataModelCommands.UpdateMeasure(batch, measureName, updatedDaxFormula); // Throws on error - - // STEP 6: Verify measure was updated - var readMeasure = _dataModelCommands.Read(batch, measureName); - Assert.True(readMeasure.Success, $"Read measure failed: {readMeasure.ErrorMessage}"); - Assert.Contains("Quantity", readMeasure.DaxFormula); - } - - /// - /// BUG INVESTIGATION TEST: Multiple sequential schema changes with DAX measures. - /// - /// This tests the compounding effect of multiple updates, which may trigger - /// the bug more reliably than a single update. - /// - [Fact] - public async Task Update_LoadedToDataModel_MultipleSchemaChanges_WithDaxMeasures() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_MultiChange_" + Guid.NewGuid().ToString("N")[..8]; - - // Version 1: 2 columns - var v1MCode = @"let - Source = #table( - {""ID"", ""Value""}, - { - {1, 100}, - {2, 200} - } - ) -in - Source"; - - // Version 2: 3 columns (add Category) - var v2MCode = @"let - Source = #table( - {""ID"", ""Value"", ""Category""}, - { - {1, 100, ""A""}, - {2, 200, ""B""} - } - ) -in - Source"; - - // Version 3: 4 columns (add Quantity) - var v3MCode = @"let - Source = #table( - {""ID"", ""Value"", ""Category"", ""Quantity""}, - { - {1, 100, ""A"", 5}, - {2, 200, ""B"", 10}, - {3, 300, ""C"", 15} - } - ) -in - Source"; - - // Version 4: Back to 3 columns (remove Category) - var v4MCode = @"let - Source = #table( - {""ID"", ""Value"", ""Quantity""}, - { - {1, 100, 5}, - {2, 200, 10}, - {3, 300, 15}, - {4, 400, 20} - } - ) -in - Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // STEP 1: Create PowerQuery V1 - _ = _powerQueryCommands.Create(batch, queryName, v1MCode, PowerQueryLoadMode.LoadToDataModel); - - // STEP 2: Create DAX measure on Value column - var measureName = "SumValue"; - var daxFormula = $"SUM('{queryName}'[Value])"; - _ = _dataModelCommands.CreateMeasure(batch, queryName, measureName, daxFormula); // Throws on error - - // STEP 3: Update to V2 (add Category) - _ = _powerQueryCommands.Update(batch, queryName, v2MCode); - var tableV2 = await _dataModelCommands.ReadTable(batch, queryName); - Assert.True(tableV2.Success, $"ReadTable V2 failed: {tableV2.ErrorMessage}"); - Assert.Equal(3, tableV2.Columns.Count); - - // STEP 4: Update to V3 (add Quantity) - _ = _powerQueryCommands.Update(batch, queryName, v3MCode); - var tableV3 = await _dataModelCommands.ReadTable(batch, queryName); - Assert.True(tableV3.Success, $"ReadTable V3 failed: {tableV3.ErrorMessage}"); - Assert.Equal(4, tableV3.Columns.Count); - - // STEP 5: Update to V4 (remove Category) - _ = _powerQueryCommands.Update(batch, queryName, v4MCode); - var tableV4 = await _dataModelCommands.ReadTable(batch, queryName); - Assert.True(tableV4.Success, $"ReadTable V4 failed: {tableV4.ErrorMessage}"); - Assert.Equal(3, tableV4.Columns.Count); // ID, Value, Quantity - - // STEP 6: Verify DAX measure still exists (it references Value which was never removed) - var measuresAfter = _dataModelCommands.ListMeasures(batch); - Assert.True(measuresAfter.Success, $"ListMeasures after failed: {measuresAfter.ErrorMessage}"); - Assert.Contains(measuresAfter.Measures, m => m.Name == measureName); - - // STEP 7: Verify load mode preserved after all changes - var loadConfig = _powerQueryCommands.GetLoadConfig(batch, queryName); - Assert.True(loadConfig.Success, $"GetLoadConfig failed: {loadConfig.ErrorMessage}"); - Assert.Equal(PowerQueryLoadMode.LoadToDataModel, loadConfig.LoadMode); - } - - /// - /// BUG INVESTIGATION TEST: Schema change with complex M code transformations. - /// - /// Tests with more realistic M code that includes transformations, - /// not just simple #table definitions. - /// - [Fact] - public async Task Update_LoadedToDataModel_ComplexMCode_SchemaChange() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_Complex_" + Guid.NewGuid().ToString("N")[..8]; - - // Initial M code with transformations - var initialMCode = @"let - Source = #table( - {""ID"", ""RawValue""}, - { - {1, 100}, - {2, 200}, - {3, 300} - } - ), - AddedColumn = Table.AddColumn(Source, ""DoubleValue"", each [RawValue] * 2), - ChangedType = Table.TransformColumnTypes(AddedColumn, {{""DoubleValue"", type number}}) -in - ChangedType"; - - // Updated M code with additional transformation step - var updatedMCode = @"let - Source = #table( - {""ID"", ""RawValue""}, - { - {1, 100}, - {2, 200}, - {3, 300}, - {4, 400} - } - ), - AddedColumn = Table.AddColumn(Source, ""DoubleValue"", each [RawValue] * 2), - AddedTriple = Table.AddColumn(AddedColumn, ""TripleValue"", each [RawValue] * 3), - ChangedType = Table.TransformColumnTypes(AddedTriple, {{""DoubleValue"", type number}, {""TripleValue"", type number}}) -in - ChangedType"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // STEP 1: Create PowerQuery with complex M code - _ = _powerQueryCommands.Create(batch, queryName, initialMCode, PowerQueryLoadMode.LoadToDataModel); - - // STEP 2: Verify initial state - should have 3 columns (ID, RawValue, DoubleValue) - var tableBefore = await _dataModelCommands.ReadTable(batch, queryName); - Assert.True(tableBefore.Success, $"ReadTable before failed: {tableBefore.ErrorMessage}"); - Assert.Equal(3, tableBefore.Columns.Count); - - // STEP 3: Create DAX measure - var measureName = "AvgDouble"; - var daxFormula = $"AVERAGE('{queryName}'[DoubleValue])"; - _ = _dataModelCommands.CreateMeasure(batch, queryName, measureName, daxFormula); // Throws on error - - // STEP 4: Update with more complex M code (adds TripleValue column) - _ = _powerQueryCommands.Update(batch, queryName, updatedMCode); - - // STEP 5: Verify schema change - should now have 4 columns - var tableAfter = await _dataModelCommands.ReadTable(batch, queryName); - Assert.True(tableAfter.Success, $"ReadTable after failed: {tableAfter.ErrorMessage}"); - Assert.Equal(4, tableAfter.Columns.Count); // ID, RawValue, DoubleValue, TripleValue - - // STEP 6: Verify DAX measure still valid - var measuresAfter = _dataModelCommands.ListMeasures(batch); - Assert.True(measuresAfter.Success, $"ListMeasures after failed: {measuresAfter.ErrorMessage}"); - Assert.Contains(measuresAfter.Measures, m => m.Name == measureName); - - // STEP 7: Verify M code was updated - var viewResult = _powerQueryCommands.View(batch, queryName); - Assert.True(viewResult.Success, $"View failed: {viewResult.ErrorMessage}"); - Assert.Contains("TripleValue", viewResult.MCode); - } - - #endregion -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.Evaluate.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.Evaluate.cs deleted file mode 100644 index 8ecbf457..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.Evaluate.cs +++ /dev/null @@ -1,209 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.PowerQuery; - -/// -/// Tests for Power Query Evaluate action. -/// Feature Issue #400: Execute M code and return results without creating a permanent query. -/// -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("RequiresExcel", "true")] -[Trait("Feature", "PowerQuery")] -[Trait("Speed", "Medium")] -public partial class PowerQueryCommandsTests -{ - /// - /// Tests evaluating a simple M code snippet that returns a table. - /// - [Fact] - public void Evaluate_SimpleTable_ReturnsData() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var mCode = @"let - Source = #table( - {""Name"", ""Value""}, - {{""Test1"", 100}, {""Test2"", 200}} - ) -in - Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Act - var result = _powerQueryCommands.Evaluate(batch, mCode); - - // Assert - Assert.True(result.Success, $"Evaluate failed: {result.ErrorMessage}"); - Assert.Equal(2, result.ColumnCount); - Assert.Equal(2, result.RowCount); - Assert.Contains("Name", result.Columns); - Assert.Contains("Value", result.Columns); - Assert.Equal(2, result.Rows.Count); - } - - /// - /// Tests evaluating M code with a single column. - /// - [Fact] - public void Evaluate_SingleColumn_ReturnsData() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var mCode = @"let - Source = #table({""SingleCol""}, {{1}, {2}, {3}}) -in - Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Act - var result = _powerQueryCommands.Evaluate(batch, mCode); - - // Assert - Assert.True(result.Success, $"Evaluate failed: {result.ErrorMessage}"); - Assert.Equal(1, result.ColumnCount); - Assert.Equal(3, result.RowCount); - Assert.Equal("SingleCol", result.Columns[0]); - } - - /// - /// Tests that invalid M code throws an error (not silent success). - /// - [Fact] - public void Evaluate_InvalidMCode_ThrowsError() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var invalidMCode = @"let - Source = UndefinedFunction() -in - Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Act & Assert - var exception = Assert.ThrowsAny(() => - _powerQueryCommands.Evaluate(batch, invalidMCode)); - - // Verify error message contains Power Query error - Assert.Contains("Expression.Error", exception.Message, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Tests that temporary query and worksheet are cleaned up after evaluation. - /// - [Fact] - public void Evaluate_AfterExecution_CleansUpTempObjects() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var mCode = @"let - Source = #table({""X""}, {{1}}) -in - Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Get initial state - var initialQueries = _powerQueryCommands.List(batch); - var initialQueryCount = initialQueries.Queries.Count; - - // Act - var result = _powerQueryCommands.Evaluate(batch, mCode); - - // Assert - Verify evaluation succeeded - Assert.True(result.Success, $"Evaluate failed: {result.ErrorMessage}"); - - // Verify no temp queries remain - var finalQueries = _powerQueryCommands.List(batch); - Assert.Equal(initialQueryCount, finalQueries.Queries.Count); - - // Verify no temp worksheets remain (__pq_eval_ prefix) - // Check worksheet count hasn't changed significantly - } - - /// - /// Tests evaluating M code with various data types. - /// - [Fact] - public void Evaluate_VariousDataTypes_ReturnsCorrectValues() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var mCode = @"let - Source = #table( - {""Text"", ""Number"", ""Boolean""}, - {{""Hello"", 42, true}, {""World"", 3.14, false}} - ) -in - Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Act - var result = _powerQueryCommands.Evaluate(batch, mCode); - - // Assert - Assert.True(result.Success, $"Evaluate failed: {result.ErrorMessage}"); - Assert.Equal(3, result.ColumnCount); - Assert.Equal(2, result.RowCount); - - // Check first row values - var firstRow = result.Rows[0]; - Assert.Equal("Hello", firstRow[0]?.ToString()); - Assert.Equal(42.0, Convert.ToDouble(firstRow[1], System.Globalization.CultureInfo.InvariantCulture)); - Assert.True(Convert.ToBoolean(firstRow[2], System.Globalization.CultureInfo.InvariantCulture)); - } - - /// - /// Tests that empty M code throws ArgumentException. - /// - [Fact] - public void Evaluate_EmptyMCode_ThrowsArgumentException() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Act & Assert - Assert.Throws(() => - _powerQueryCommands.Evaluate(batch, "")); - } - - /// - /// Tests evaluating M code with data transformations. - /// - [Fact] - public void Evaluate_WithTransformations_ReturnsTransformedData() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var mCode = @"let - Source = #table({""Value""}, {{1}, {2}, {3}, {4}, {5}}), - Filtered = Table.SelectRows(Source, each [Value] > 2), - Added = Table.AddColumn(Filtered, ""Doubled"", each [Value] * 2) -in - Added"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Act - var result = _powerQueryCommands.Evaluate(batch, mCode); - - // Assert - Assert.True(result.Success, $"Evaluate failed: {result.ErrorMessage}"); - Assert.Equal(2, result.ColumnCount); // Value, Doubled - Assert.Equal(3, result.RowCount); // Values 3, 4, 5 - Assert.Contains("Doubled", result.Columns); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.LifecycleCleanup.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.LifecycleCleanup.cs deleted file mode 100644 index 12f7c311..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.LifecycleCleanup.cs +++ /dev/null @@ -1,389 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands; -using Sbroenne.ExcelMcp.Core.Models; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.PowerQuery; - -/// -/// Tests for Power Query lifecycle cleanup operations. -/// -/// These tests validate that: -/// - Unload correctly removes Data Model connections -/// - Delete correctly removes Data Model connections (no orphans) -/// - List correctly reports IsConnectionOnly for Data Model queries -/// -/// -/// Created for GitHub Issue #279: Power Query Lifecycle bugs -/// -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("Feature", "PowerQuery")] -[Trait("Feature", "DataModel")] -[Trait("RequiresExcel", "true")] -[Trait("Speed", "Medium")] -public class PowerQueryLifecycleCleanupTests : IClassFixture -{ - private readonly PowerQueryCommands _powerQueryCommands; - private readonly DataModelCommands _dataModelCommands; - private readonly ConnectionCommands _connectionCommands; - private readonly TempDirectoryFixture _fixture; - - public PowerQueryLifecycleCleanupTests(TempDirectoryFixture fixture) - { - _dataModelCommands = new DataModelCommands(); - _powerQueryCommands = new PowerQueryCommands(_dataModelCommands); - _connectionCommands = new ConnectionCommands(); - _fixture = fixture; - } - - #region Unload Tests - Data Model Connection Cleanup - - /// - /// Issue #279 Fix 1: Unload with LoadToDataModel should remove the Data Model connection. - /// Before fix: Connection remained orphaned after unload. - /// - [Fact] - public async Task Unload_DataModelOnly_RemovesDataModelConnection() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_UnloadDM_" + Guid.NewGuid().ToString("N")[..8]; - var mCode = @"let Source = #table({""Val""}, {{1}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create query with LoadToDataModel - _ = _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToDataModel); - - // Verify Data Model table exists - var tablesBefore = await _dataModelCommands.ListTables(batch); - Assert.True(tablesBefore.Success); - Assert.Contains(tablesBefore.Tables, t => t.Name == queryName); - - // Verify connection exists (pattern: "Query - {queryName}") - var connsBefore = _connectionCommands.List(batch); - Assert.True(connsBefore.Success); - Assert.Contains(connsBefore.Connections, c => c.Name.Contains($"Query - {queryName}")); - - // Act - Unload the query - var unloadResult = _powerQueryCommands.Unload(batch, queryName); - - // Assert - Assert.True(unloadResult.Success, $"Unload failed: {unloadResult.ErrorMessage}"); - - // Verify Data Model connection is removed - var connsAfter = _connectionCommands.List(batch); - Assert.True(connsAfter.Success); - Assert.DoesNotContain(connsAfter.Connections, c => c.Name.Contains($"Query - {queryName}")); - - // Verify query still exists (just unloaded, not deleted) - var queries = _powerQueryCommands.List(batch); - Assert.True(queries.Success); - Assert.Contains(queries.Queries, q => q.Name == queryName); - - // Verify query is now ConnectionOnly - var loadConfig = _powerQueryCommands.GetLoadConfig(batch, queryName); - Assert.True(loadConfig.Success); - Assert.Equal(PowerQueryLoadMode.ConnectionOnly, loadConfig.LoadMode); - } - - /// - /// Issue #279 Fix 1: Unload with LoadToBoth should remove both worksheet data AND Data Model connection. - /// - [Fact] - public async Task Unload_LoadToBoth_RemovesBothWorksheetAndDataModelConnection() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_UnloadBoth_" + Guid.NewGuid().ToString("N")[..8]; - var sheetName = "BothSheet"; - var mCode = @"let Source = #table({""Val""}, {{42}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create query with LoadToBoth - _ = _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToBoth, sheetName); - - // Verify Data Model table exists - var tablesBefore = await _dataModelCommands.ListTables(batch); - Assert.True(tablesBefore.Success); - Assert.Contains(tablesBefore.Tables, t => t.Name == queryName); - - // Verify connection exists - var connsBefore = _connectionCommands.List(batch); - Assert.True(connsBefore.Success); - Assert.Contains(connsBefore.Connections, c => c.Name.Contains($"Query - {queryName}")); - - // Act - Unload the query - var unloadResult = _powerQueryCommands.Unload(batch, queryName); - - // Assert - Assert.True(unloadResult.Success, $"Unload failed: {unloadResult.ErrorMessage}"); - - // Verify Data Model connection is removed - var connsAfter = _connectionCommands.List(batch); - Assert.True(connsAfter.Success); - Assert.DoesNotContain(connsAfter.Connections, c => c.Name.Contains($"Query - {queryName}")); - - // Verify query is now ConnectionOnly - var loadConfig = _powerQueryCommands.GetLoadConfig(batch, queryName); - Assert.True(loadConfig.Success); - Assert.Equal(PowerQueryLoadMode.ConnectionOnly, loadConfig.LoadMode); - } - - #endregion - - #region Delete Tests - Data Model Connection Cleanup - - /// - /// Issue #279 Fix 3: Delete with LoadToDataModel should remove the Data Model connection. - /// Before fix: Connection remained orphaned after delete. - /// - [Fact] - public async Task Delete_DataModelOnly_RemovesDataModelConnection() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_DeleteDM_" + Guid.NewGuid().ToString("N")[..8]; - var mCode = @"let Source = #table({""Val""}, {{1}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create query with LoadToDataModel - _ = _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToDataModel); - - // Verify Data Model table exists - var tablesBefore = await _dataModelCommands.ListTables(batch); - Assert.True(tablesBefore.Success); - Assert.Contains(tablesBefore.Tables, t => t.Name == queryName); - - // Verify connection exists - var connsBefore = _connectionCommands.List(batch); - Assert.True(connsBefore.Success); - Assert.Contains(connsBefore.Connections, c => c.Name.Contains($"Query - {queryName}")); - - // Act - Delete the query - _ = _powerQueryCommands.Delete(batch, queryName); - - // Assert - Verify query is gone - var queries = _powerQueryCommands.List(batch); - Assert.True(queries.Success); - Assert.DoesNotContain(queries.Queries, q => q.Name == queryName); - - // Verify Data Model connection is removed (no orphans) - var connsAfter = _connectionCommands.List(batch); - Assert.True(connsAfter.Success); - Assert.DoesNotContain(connsAfter.Connections, c => c.Name.Contains($"Query - {queryName}")); - } - - /// - /// Issue #279 Fix 3: Delete with LoadToBoth should remove both worksheet data AND Data Model connection. - /// - [Fact] - public async Task Delete_LoadToBoth_RemovesBothWorksheetAndDataModelConnection() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_DeleteBoth_" + Guid.NewGuid().ToString("N")[..8]; - var sheetName = "DeleteBothSheet"; - var mCode = @"let Source = #table({""Val""}, {{99}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create query with LoadToBoth - _ = _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToBoth, sheetName); - - // Verify Data Model table exists - var tablesBefore = await _dataModelCommands.ListTables(batch); - Assert.True(tablesBefore.Success); - Assert.Contains(tablesBefore.Tables, t => t.Name == queryName); - - // Verify connection exists - var connsBefore = _connectionCommands.List(batch); - Assert.True(connsBefore.Success); - Assert.Contains(connsBefore.Connections, c => c.Name.Contains($"Query - {queryName}")); - - // Act - Delete the query - _ = _powerQueryCommands.Delete(batch, queryName); - - // Assert - Verify query is gone - var queries = _powerQueryCommands.List(batch); - Assert.True(queries.Success); - Assert.DoesNotContain(queries.Queries, q => q.Name == queryName); - - // Verify Data Model connection is removed (no orphans) - var connsAfter = _connectionCommands.List(batch); - Assert.True(connsAfter.Success); - Assert.DoesNotContain(connsAfter.Connections, c => c.Name.Contains($"Query - {queryName}")); - } - - #endregion - - #region List IsConnectionOnly Tests - Data Model Awareness - - /// - /// Issue #279 Fix 2: List should NOT report IsConnectionOnly=true for Data Model queries. - /// Before fix: Queries loaded ONLY to Data Model were reported as ConnectionOnly. - /// - [Fact] - public void List_DataModelOnly_NotReportedAsConnectionOnly() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_ListDM_" + Guid.NewGuid().ToString("N")[..8]; - var mCode = @"let Source = #table({""Val""}, {{1}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create query with LoadToDataModel - _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToDataModel); - - // Act - List queries - var listResult = _powerQueryCommands.List(batch); - - // Assert - Assert.True(listResult.Success, $"List failed: {listResult.ErrorMessage}"); - var query = listResult.Queries.FirstOrDefault(q => q.Name == queryName); - Assert.NotNull(query); - - // CRITICAL: LoadToDataModel should NOT be reported as ConnectionOnly - Assert.False(query.IsConnectionOnly, - "Query loaded to Data Model should NOT be reported as ConnectionOnly"); - } - - /// - /// List should correctly report ConnectionOnly for queries with no load destination. - /// - [Fact] - public void List_ConnectionOnly_ReportsAsConnectionOnly() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_ListConnOnly_" + Guid.NewGuid().ToString("N")[..8]; - var mCode = @"let Source = #table({""Val""}, {{1}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create query with ConnectionOnly - _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.ConnectionOnly); - - // Act - List queries - var listResult = _powerQueryCommands.List(batch); - - // Assert - Assert.True(listResult.Success, $"List failed: {listResult.ErrorMessage}"); - var query = listResult.Queries.FirstOrDefault(q => q.Name == queryName); - Assert.NotNull(query); - Assert.True(query.IsConnectionOnly, - "Query with ConnectionOnly should be reported as ConnectionOnly"); - } - - /// - /// List should NOT report IsConnectionOnly=true for queries loaded to worksheet. - /// - [Fact] - public void List_LoadToTable_NotReportedAsConnectionOnly() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_ListTable_" + Guid.NewGuid().ToString("N")[..8]; - var mCode = @"let Source = #table({""Val""}, {{1}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create query with LoadToTable - _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToTable); - - // Act - List queries - var listResult = _powerQueryCommands.List(batch); - - // Assert - Assert.True(listResult.Success, $"List failed: {listResult.ErrorMessage}"); - var query = listResult.Queries.FirstOrDefault(q => q.Name == queryName); - Assert.NotNull(query); - Assert.False(query.IsConnectionOnly, - "Query loaded to table should NOT be reported as ConnectionOnly"); - } - - /// - /// List should NOT report IsConnectionOnly=true for LoadToBoth queries. - /// - [Fact] - public void List_LoadToBoth_NotReportedAsConnectionOnly() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_ListBoth_" + Guid.NewGuid().ToString("N")[..8]; - var mCode = @"let Source = #table({""Val""}, {{1}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create query with LoadToBoth - _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToBoth); - - // Act - List queries - var listResult = _powerQueryCommands.List(batch); - - // Assert - Assert.True(listResult.Success, $"List failed: {listResult.ErrorMessage}"); - var query = listResult.Queries.FirstOrDefault(q => q.Name == queryName); - Assert.NotNull(query); - Assert.False(query.IsConnectionOnly, - "Query loaded to both should NOT be reported as ConnectionOnly"); - } - - /// - /// Mixed scenario: Correctly identify ConnectionOnly among multiple query types. - /// - [Fact] - public void List_MixedLoadModes_CorrectlyIdentifiesConnectionOnlyQueries() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var suffix = Guid.NewGuid().ToString("N")[..6]; - var queryConnOnly = "PQ_Mix_ConnOnly_" + suffix; - var queryTable = "PQ_Mix_Table_" + suffix; - var queryDataModel = "PQ_Mix_DataModel_" + suffix; - var queryBoth = "PQ_Mix_Both_" + suffix; - - var mCode = @"let Source = #table({""A""}, {{1}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create queries with different load modes - _powerQueryCommands.Create(batch, queryConnOnly, mCode, PowerQueryLoadMode.ConnectionOnly); - _powerQueryCommands.Create(batch, queryTable, mCode, PowerQueryLoadMode.LoadToTable, "Sheet1"); - _powerQueryCommands.Create(batch, queryDataModel, mCode, PowerQueryLoadMode.LoadToDataModel); - _powerQueryCommands.Create(batch, queryBoth, mCode, PowerQueryLoadMode.LoadToBoth, "Sheet2"); - - // Act - var listResult = _powerQueryCommands.List(batch); - - // Assert - Assert.True(listResult.Success, $"List failed: {listResult.ErrorMessage}"); - - var connOnlyQuery = listResult.Queries.FirstOrDefault(q => q.Name == queryConnOnly); - var tableQuery = listResult.Queries.FirstOrDefault(q => q.Name == queryTable); - var dataModelQuery = listResult.Queries.FirstOrDefault(q => q.Name == queryDataModel); - var bothQuery = listResult.Queries.FirstOrDefault(q => q.Name == queryBoth); - - Assert.NotNull(connOnlyQuery); - Assert.NotNull(tableQuery); - Assert.NotNull(dataModelQuery); - Assert.NotNull(bothQuery); - - // ONLY ConnectionOnly should report IsConnectionOnly=true - Assert.True(connOnlyQuery.IsConnectionOnly, "ConnectionOnly query should be ConnectionOnly"); - Assert.False(tableQuery.IsConnectionOnly, "LoadToTable should NOT be ConnectionOnly"); - Assert.False(dataModelQuery.IsConnectionOnly, "LoadToDataModel should NOT be ConnectionOnly"); - Assert.False(bothQuery.IsConnectionOnly, "LoadToBoth should NOT be ConnectionOnly"); - } - - #endregion -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.MCodeFormatting.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.MCodeFormatting.cs deleted file mode 100644 index 65a0e5bd..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.MCodeFormatting.cs +++ /dev/null @@ -1,299 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands; -using Sbroenne.ExcelMcp.Core.Models; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.PowerQuery; - -/// -/// Integration tests for M-code formatting feature. -/// Tests verify that M code (Power Query) is automatically formatted on write operations. -/// Write operations (Create, Update) format M code via powerqueryformatter.com API. -/// Read operations (List, View) return M code as stored in the workbook. -/// Note: Formatting may add newlines and spacing, but these tests focus on content preservation. -/// -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("RequiresExcel", "true")] -[Trait("Feature", "PowerQuery")] -[Trait("Speed", "Medium")] -public class PowerQueryCommandsTests_MCodeFormatting : IClassFixture -{ - private readonly PowerQueryCommands _powerQueryCommands; - private readonly PowerQueryTestsFixture _fixture; - - public PowerQueryCommandsTests_MCodeFormatting(PowerQueryTestsFixture fixture) - { - _fixture = fixture; - var dataModelCommands = new DataModelCommands(); - _powerQueryCommands = new PowerQueryCommands(dataModelCommands); - } - - /// - /// Tests that Create preserves M code content. - /// Verifies that the query can be created and the M code is stored correctly. - /// Formatting may add newlines/spacing, but core content should be preserved. - /// - [Fact] - public void Create_WithUnformattedMCode_PreservesContent() - { - var testFile = _fixture.CreateTestFile(); - var queryName = $"Test_CreateFormatted_{Guid.NewGuid():N}"[..30]; - - // Unformatted M code (single line, no spaces, compact) - var unformattedMCode = "let Source=Excel.CurrentWorkbook(){[Name=\"Table1\"]}[Content],Filtered=Table.SelectRows(Source,each [Column1]>5) in Filtered"; - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create query (should format automatically) - _powerQueryCommands.Create(batch, queryName, unformattedMCode, PowerQueryLoadMode.ConnectionOnly); - - // Retrieve and verify - var viewResult = _powerQueryCommands.View(batch, queryName); - Assert.True(viewResult.Success, $"View failed: {viewResult.ErrorMessage}"); - - // The formatted version should contain the core function names - // (formatting may add whitespace/newlines but preserves content) - Assert.Contains("let", viewResult.MCode, StringComparison.OrdinalIgnoreCase); - Assert.Contains("Excel.CurrentWorkbook", viewResult.MCode, StringComparison.OrdinalIgnoreCase); - Assert.Contains("Table.SelectRows", viewResult.MCode, StringComparison.OrdinalIgnoreCase); - Assert.Contains("in", viewResult.MCode, StringComparison.OrdinalIgnoreCase); - Assert.NotEmpty(viewResult.MCode); - } - - /// - /// Tests that Update preserves M code content. - /// Verifies that the query can be updated and the M code is stored correctly. - /// - [Fact] - public void Update_WithUnformattedMCode_PreservesContent() - { - var testFile = _fixture.CreateTestFile(); - var queryName = $"Test_UpdateFormatted_{Guid.NewGuid():N}"[..30]; - - var originalMCode = @"let - Source = 1 -in - Source"; - - // Unformatted update M code (single line, no spaces) - var unformattedUpdate = "let Source=#table({\"A\",\"B\"},{{1,2},{3,4}}),Filtered=Table.SelectRows(Source,each [A]>1) in Filtered"; - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create query - _powerQueryCommands.Create(batch, queryName, originalMCode, PowerQueryLoadMode.ConnectionOnly); - - // Update with unformatted M code (should format automatically) - _powerQueryCommands.Update(batch, queryName, unformattedUpdate, refresh: false); - - // Retrieve and verify - var viewResult = _powerQueryCommands.View(batch, queryName); - Assert.True(viewResult.Success, $"View failed: {viewResult.ErrorMessage}"); - - // The formatted version should contain the core function names - Assert.Contains("#table", viewResult.MCode); - Assert.Contains("Table.SelectRows", viewResult.MCode); - Assert.NotEmpty(viewResult.MCode); - } - - /// - /// Tests that pre-formatted M code is preserved/enhanced by the formatter. - /// Verifies that already-formatted code doesn't break. - /// - [Fact] - public void Create_WithPreformattedMCode_PreservesReadability() - { - var testFile = _fixture.CreateTestFile(); - var queryName = $"Test_PreFormatted_{Guid.NewGuid():N}"[..30]; - - // Pre-formatted M code (with proper indentation) - var preformattedMCode = @"let - Source = #table( - {""ProductID"", ""ProductName"", ""Price""}, - { - {1, ""Widget"", 10.99}, - {2, ""Gadget"", 25.50} - } - ) -in - Source"; - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create query with pre-formatted M code - _powerQueryCommands.Create(batch, queryName, preformattedMCode, PowerQueryLoadMode.ConnectionOnly); - - // Retrieve and verify - var viewResult = _powerQueryCommands.View(batch, queryName); - Assert.True(viewResult.Success, $"View failed: {viewResult.ErrorMessage}"); - - // Should still contain the structure (formatting shouldn't break it) - Assert.Contains("#table", viewResult.MCode); - Assert.Contains("ProductID", viewResult.MCode); - Assert.Contains("let", viewResult.MCode, StringComparison.OrdinalIgnoreCase); - Assert.Contains("in", viewResult.MCode, StringComparison.OrdinalIgnoreCase); - Assert.NotEmpty(viewResult.MCode); - } - - /// - /// Tests that empty or whitespace M code is handled gracefully. - /// The Create operation should fail validation before reaching the formatter. - /// - [Fact] - public void Create_WithEmptyMCode_ThrowsArgumentException() - { - var testFile = _fixture.CreateTestFile(); - var queryName = $"Test_Empty_{Guid.NewGuid():N}"[..30]; - - using var batch = ExcelSession.BeginBatch(testFile); - - // Empty M code should fail validation (not reach formatter) - Assert.Throws(() => - _powerQueryCommands.Create(batch, queryName, "", PowerQueryLoadMode.ConnectionOnly)); - - // Whitespace-only M code should also fail - Assert.Throws(() => - _powerQueryCommands.Create(batch, queryName, " ", PowerQueryLoadMode.ConnectionOnly)); - } - - /// - /// Tests that View returns M code as stored (formatter applied on write). - /// Verifies that read operations don't re-format. - /// - [Fact] - public void View_AfterCreate_ReturnsMCodeAsStored() - { - var testFile = _fixture.CreateTestFile(); - var queryName = $"Test_ViewStored_{Guid.NewGuid():N}"[..30]; - - // Simple M code - var mCode = "let x=1 in x"; - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create query - _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.ConnectionOnly); - - // View twice - should return same result each time - var viewResult1 = _powerQueryCommands.View(batch, queryName); - var viewResult2 = _powerQueryCommands.View(batch, queryName); - - Assert.True(viewResult1.Success); - Assert.True(viewResult2.Success); - - // M code should be identical on both reads (no re-formatting on read) - Assert.Equal(viewResult1.MCode, viewResult2.MCode); - } - - /// - /// Tests that List returns queries with M code intact. - /// Verifies that list operation doesn't affect stored M code. - /// - [Fact] - public void List_AfterCreate_ReturnsQueryWithMCode() - { - var testFile = _fixture.CreateTestFile(); - var queryName = $"Test_ListQuery_{Guid.NewGuid():N}"[..30]; - - // Unformatted M code - var unformattedMCode = "let Source=1,Result=Source+1 in Result"; - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create query - _powerQueryCommands.Create(batch, queryName, unformattedMCode, PowerQueryLoadMode.ConnectionOnly); - - // List queries - var listResult = _powerQueryCommands.List(batch); - Assert.True(listResult.Success, $"List failed: {listResult.ErrorMessage}"); - - // Find our query - var query = listResult.Queries.FirstOrDefault(q => q.Name == queryName); - Assert.NotNull(query); - - // Verify query has M code preview - Assert.False(string.IsNullOrEmpty(query.FormulaPreview)); - } - - /// - /// Tests that complex M code with multiple steps is properly handled. - /// Verifies that multi-step queries maintain their structure. - /// - [Fact] - public void Create_WithComplexMultiStepMCode_PreservesAllSteps() - { - var testFile = _fixture.CreateTestFile(); - var queryName = $"Test_Complex_{Guid.NewGuid():N}"[..30]; - - // Complex unformatted M code with multiple steps - var complexMCode = "let Source=#table({\"A\",\"B\",\"C\"},{{1,2,3},{4,5,6},{7,8,9}}),Filtered=Table.SelectRows(Source,each [A]>3),Transformed=Table.TransformColumnTypes(Filtered,{{\"A\",type number},{\"B\",type number},{\"C\",type number}}),Added=Table.AddColumn(Transformed,\"Sum\",each [A]+[B]+[C]) in Added"; - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create query (should format automatically) - _powerQueryCommands.Create(batch, queryName, complexMCode, PowerQueryLoadMode.ConnectionOnly); - - // Retrieve and verify - var viewResult = _powerQueryCommands.View(batch, queryName); - Assert.True(viewResult.Success, $"View failed: {viewResult.ErrorMessage}"); - - // Verify all steps are present - Assert.Contains("#table", viewResult.MCode); - Assert.Contains("Table.SelectRows", viewResult.MCode); - Assert.Contains("Table.TransformColumnTypes", viewResult.MCode); - Assert.Contains("Table.AddColumn", viewResult.MCode); - Assert.NotEmpty(viewResult.MCode); - - // Verify the query can still be viewed without errors (formatting didn't corrupt it) - var verifyResult = _powerQueryCommands.View(batch, queryName); - Assert.True(verifyResult.Success); - } - - /// - /// Tests that sequential Create and Update operations both preserve content. - /// Verifies that formatting is consistent across operations. - /// - [Fact] - public void CreateThenUpdate_BothOperationsPreserveContent() - { - var testFile = _fixture.CreateTestFile(); - var queryName = $"Test_Sequential_{Guid.NewGuid():N}"[..30]; - - // First unformatted M code - var createMCode = "let x=1,y=2 in x+y"; - - // Second unformatted M code - var updateMCode = "let a=10,b=20,c=30 in a+b+c"; - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create query - _powerQueryCommands.Create(batch, queryName, createMCode, PowerQueryLoadMode.ConnectionOnly); - - var afterCreate = _powerQueryCommands.View(batch, queryName); - Assert.True(afterCreate.Success); - Assert.NotEmpty(afterCreate.MCode); - - // Update query - _powerQueryCommands.Update(batch, queryName, updateMCode, refresh: false); - - var afterUpdate = _powerQueryCommands.View(batch, queryName); - Assert.True(afterUpdate.Success); - Assert.NotEmpty(afterUpdate.MCode); - - // New content should be present - Assert.Contains("a", afterUpdate.MCode); - Assert.Contains("b", afterUpdate.MCode); - Assert.Contains("c", afterUpdate.MCode); - - // Old unique values should be replaced (x=1, y=2) - Assert.DoesNotContain("x = 1", afterUpdate.MCode); - Assert.DoesNotContain("y = 2", afterUpdate.MCode); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.ManualTable.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.ManualTable.cs deleted file mode 100644 index 8c880949..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.ManualTable.cs +++ /dev/null @@ -1,300 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands; -using Sbroenne.ExcelMcp.Core.Models; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.PowerQuery; - -/// -/// Tests verifying List() handles manually created tables (ListObjects without QueryTables) -/// -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("RequiresExcel", "true")] -[Trait("Feature", "PowerQuery")] -[Trait("Speed", "Medium")] -public partial class PowerQueryCommandsTests -{ - /// - /// Verifies List() handles workbooks with both Power Queries AND manually created tables - /// Manually created tables don't have QueryTable property and throw 0x800A03EC - /// List() should skip those tables gracefully without creating "Error Query" entries - /// - [Fact] - public void List_WorkbookWithManualTable_ReturnsOnlyQueries() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - var dataModelCommands = new DataModelCommands(); - var commands = new PowerQueryCommands(dataModelCommands); - - const string queryName = "TestQuery"; - const string mCode = @"let - Source = #table( - {""Column1"", ""Column2""}, - { - {""A"", ""B""}, - {""C"", ""D""} - } - ) -in - Source"; - - // Act - using var batch = ExcelSession.BeginBatch(testFile); - - // Step 1: Create a manually created table (no Power Query connection) - batch.Execute((ctx, ct) => - { - dynamic? sheet = null; - dynamic? range = null; - dynamic? listObjects = null; - try - { - sheet = ctx.Book.Worksheets[1]; - sheet.Name = "TestSheet"; - - // Add some data - range = sheet.Range["A1:B3"]; - range.Value2 = new object[,] - { - { "Header1", "Header2" }, - { "Data1", "Data2" }, - { "Data3", "Data4" } - }; - - // Create a manual table (ListObject) - NO QueryTable - listObjects = sheet.ListObjects; - dynamic? listObject = listObjects.Add( - 1, // xlSrcRange (manual table from range) - range, // Source range - Type.Missing, // LinkSource - 1, // xlYes (has headers) - Type.Missing // Destination - ); - listObject.Name = "ManualTable"; - ComUtilities.Release(ref listObject!); - } - finally - { - ComUtilities.Release(ref listObjects!); - ComUtilities.Release(ref range!); - ComUtilities.Release(ref sheet!); - } - }); - - // Step 2: Create a Power Query (connection-only) - commands.Create(batch, queryName, mCode, PowerQueryLoadMode.ConnectionOnly); - - // Step 3: List queries - var result = commands.List(batch); - - // Assert - Assert.True(result.Success, $"List failed: {result.ErrorMessage}"); - Assert.NotNull(result.Queries); - - // Should have 1 query (not "Error Query" entries from manual table) - Assert.Single(result.Queries); - - // Verify NO "Error Query" fake entries - Assert.DoesNotContain(result.Queries, q => q.Name.StartsWith("Error Query", StringComparison.Ordinal)); - - // Verify the actual query is present - var query = Assert.Single(result.Queries); - Assert.Equal(queryName, query.Name); - Assert.NotEmpty(query.Formula); - Assert.DoesNotContain("Error:", query.FormulaPreview); - - // Query should be connection-only (manual table shouldn't affect this) - Assert.True(query.IsConnectionOnly); - } - - /// - /// Regression test: Verifies View() handles workbooks with manually created tables - /// Bug: View() was throwing COMException 0x800A03EC when iterating ListObjects - /// because manually created tables don't have QueryTable property. - /// Fix: View() now catches COMException when accessing ListObject.QueryTable and skips non-query tables. - /// - [Fact] - public void View_WorkbookWithManualTable_ReturnsQueryDetails() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - var dataModelCommands = new DataModelCommands(); - var commands = new PowerQueryCommands(dataModelCommands); - - const string queryName = "TestQuery"; - const string mCode = @"let - Source = #table( - {""Column1"", ""Column2""}, - { - {""A"", ""B""}, - {""C"", ""D""} - } - ) -in - Source"; - - // Act - using var batch = ExcelSession.BeginBatch(testFile); - - // Step 1: Create a manually created table (no Power Query connection) - batch.Execute((ctx, ct) => - { - dynamic? sheet = null; - dynamic? range = null; - dynamic? listObjects = null; - try - { - sheet = ctx.Book.Worksheets[1]; - sheet.Name = "TestSheet"; - - // Add some data - range = sheet.Range["A1:B3"]; - range.Value2 = new object[,] - { - { "Header1", "Header2" }, - { "Data1", "Data2" }, - { "Data3", "Data4" } - }; - - // Create a manual table (ListObject) - NO QueryTable - listObjects = sheet.ListObjects; - dynamic? listObject = listObjects.Add( - 1, // xlSrcRange (manual table from range) - range, // Source range - Type.Missing, // LinkSource - 1, // xlYes (has headers) - Type.Missing // Destination - ); - listObject.Name = "ManualTable"; - ComUtilities.Release(ref listObject!); - } - finally - { - ComUtilities.Release(ref listObjects!); - ComUtilities.Release(ref range!); - ComUtilities.Release(ref sheet!); - } - }); - - // Step 2: Create a Power Query (connection-only) - commands.Create(batch, queryName, mCode, PowerQueryLoadMode.ConnectionOnly); - - // Step 3: View the query - THIS WAS THROWING 0x800A03EC before the fix - var result = commands.View(batch, queryName); - - // Assert - Assert.True(result.Success, $"View failed: {result.ErrorMessage}"); - Assert.Equal(queryName, result.QueryName); - Assert.NotEmpty(result.MCode); - Assert.Contains("Source = #table", result.MCode); - - // Verify load destination detected correctly despite manual table presence - Assert.True(result.IsConnectionOnly); - } - - /// - /// Regression test: Verifies Update() handles workbooks with manually created tables - /// Bug: Update() was throwing COMException 0x800A03EC when iterating ListObjects - /// because manually created tables don't have QueryTable property. - /// Fix: Update() now catches COMException when accessing ListObject.QueryTable and skips non-query tables. - /// - [Fact] - public void Update_WorkbookWithManualTable_UpdatesQuerySuccessfully() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - var dataModelCommands = new DataModelCommands(); - var commands = new PowerQueryCommands(dataModelCommands); - - const string queryName = "TestQuery"; - const string originalMCode = @"let - Source = #table( - {""Column1"", ""Column2""}, - { - {""A"", ""B""}, - {""C"", ""D""} - } - ) -in - Source"; - - const string updatedMCode = @"let - Source = #table( - {""NewCol1"", ""NewCol2"", ""NewCol3""}, - { - {1, 2, 3}, - {4, 5, 6} - } - ) -in - Source"; - - // Act - using var batch = ExcelSession.BeginBatch(testFile); - - // Step 1: Create a manually created table (no Power Query connection) - batch.Execute((ctx, ct) => - { - dynamic? sheet = null; - dynamic? range = null; - dynamic? listObjects = null; - try - { - sheet = ctx.Book.Worksheets[1]; - sheet.Name = "TestSheet"; - - // Add some data - range = sheet.Range["A1:B3"]; - range.Value2 = new object[,] - { - { "Header1", "Header2" }, - { "Data1", "Data2" }, - { "Data3", "Data4" } - }; - - // Create a manual table (ListObject) - NO QueryTable - listObjects = sheet.ListObjects; - dynamic? listObject = listObjects.Add( - 1, // xlSrcRange (manual table from range) - range, // Source range - Type.Missing, // LinkSource - 1, // xlYes (has headers) - Type.Missing // Destination - ); - listObject.Name = "ManualTable"; - ComUtilities.Release(ref listObject!); - } - finally - { - ComUtilities.Release(ref listObjects!); - ComUtilities.Release(ref range!); - ComUtilities.Release(ref sheet!); - } - }); - - // Step 2: Create a Power Query (connection-only) - commands.Create(batch, queryName, originalMCode, PowerQueryLoadMode.ConnectionOnly); - - // Step 3: Update the query - THIS WAS THROWING 0x800A03EC before the fix - commands.Update(batch, queryName, updatedMCode); - - // Step 4: Verify the update by viewing - var viewResult = commands.View(batch, queryName); - Assert.True(viewResult.Success, $"View after update failed: {viewResult.ErrorMessage}"); - Assert.Contains("NewCol1", viewResult.MCode); - Assert.Contains("NewCol2", viewResult.MCode); - Assert.Contains("NewCol3", viewResult.MCode); - Assert.DoesNotContain("Column1", viewResult.MCode); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.MixedConnectionTypes.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.MixedConnectionTypes.cs deleted file mode 100644 index 77b4c337..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.MixedConnectionTypes.cs +++ /dev/null @@ -1,300 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands; -using Sbroenne.ExcelMcp.Core.Models; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; -using Xunit.Abstractions; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.PowerQuery; - -/// -/// Regression tests for workbooks with mixed connection types. -/// -/// Root cause (Issue #323): When a workbook contains non-OLEDB connections -/// (Type=7 ThisWorkbookDataModel, Type=8 workbook data-model connections), -/// accessing .OLEDBConnection on them throws COMException 0x800A03EC. -/// -/// The Update/View/Lifecycle code iterates all connections and must gracefully -/// skip non-OLEDB connection types. Loading a Power Query to the Data Model -/// creates a ThisWorkbookDataModel (Type=7) connection, which reproduces the bug. -/// -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("RequiresExcel", "true")] -[Trait("Feature", "PowerQuery")] -[Trait("Speed", "Medium")] -public class PowerQueryMixedConnectionTypeTests : IClassFixture -{ - private readonly PowerQueryCommands _powerQueryCommands; - private readonly DataModelCommands _dataModelCommands; - private readonly TempDirectoryFixture _fixture; - private readonly ITestOutputHelper _output; - - public PowerQueryMixedConnectionTypeTests(TempDirectoryFixture fixture, ITestOutputHelper output) - { - _dataModelCommands = new DataModelCommands(); - _powerQueryCommands = new PowerQueryCommands(_dataModelCommands); - _fixture = fixture; - _output = output; - } - - /// - /// Regression test for Issue #323: Update on a connection-only query crashes with - /// COMException 0x800A03EC when the workbook also has a Data Model connection (Type=7). - /// - /// Scenario: Create query A loaded to Data Model (produces ThisWorkbookDataModel - /// Type=7 connection), then Update query B (connection-only) with refresh. - /// Before fix: COMException 0x800A03EC thrown iterating connections. - /// After fix: Update succeeds, skipping non-OLEDB connections. - /// - [Fact] - public void Update_WithDataModelConnection_DoesNotThrowCOMException() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - var dataModelQueryName = "PQ_DM_" + Guid.NewGuid().ToString("N")[..8]; - var connOnlyQueryName = "PQ_CO_" + Guid.NewGuid().ToString("N")[..8]; - - var dataModelMCode = @"let Source = #table({""ID"", ""Value""}, {{1, 100}, {2, 200}}) in Source"; - var connOnlyMCode = @"let Source = #table({""A""}, {{1}}) in Source"; - var updatedMCode = @"let Source = #table({""A"", ""B""}, {{1, 2}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testFile); - - // STEP 1: Create a query loaded to Data Model → produces Type=7 connection - _powerQueryCommands.Create(batch, dataModelQueryName, dataModelMCode, PowerQueryLoadMode.LoadToDataModel); - - // Verify Data Model connection exists - var loadConfig = _powerQueryCommands.GetLoadConfig(batch, dataModelQueryName); - Assert.True(loadConfig.Success, $"GetLoadConfig failed: {loadConfig.ErrorMessage}"); - Assert.Equal(PowerQueryLoadMode.LoadToDataModel, loadConfig.LoadMode); - _output.WriteLine($"Data Model query '{dataModelQueryName}' created with LoadToDataModel"); - - // STEP 2: Create a connection-only query - _powerQueryCommands.Create(batch, connOnlyQueryName, connOnlyMCode, PowerQueryLoadMode.ConnectionOnly); - _output.WriteLine($"Connection-only query '{connOnlyQueryName}' created"); - - // STEP 3: Update the connection-only query - this is what crashed before the fix - // The Update code iterates all connections including ThisWorkbookDataModel (Type=7) - var updateResult = _powerQueryCommands.Update(batch, connOnlyQueryName, updatedMCode); - - // Assert - no COMException thrown, update succeeds - Assert.True(updateResult.Success, $"Update failed: {updateResult.ErrorMessage}"); - _output.WriteLine($"Update succeeded on '{connOnlyQueryName}' in presence of Data Model connection"); - - // Verify code was actually updated - var viewResult = _powerQueryCommands.View(batch, connOnlyQueryName); - Assert.True(viewResult.Success, $"View failed: {viewResult.ErrorMessage}"); - Assert.Contains("\"B\"", viewResult.MCode); - } - - /// - /// Regression test: View on a connection-only query should not crash when - /// the workbook has a Data Model connection (Type=7). - /// - [Fact] - public void View_WithDataModelConnection_DoesNotThrowCOMException() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - var dataModelQueryName = "PQ_DM_" + Guid.NewGuid().ToString("N")[..8]; - var connOnlyQueryName = "PQ_CO_" + Guid.NewGuid().ToString("N")[..8]; - - var dataModelMCode = @"let Source = #table({""X""}, {{1}}) in Source"; - var connOnlyMCode = @"let Source = #table({""Y""}, {{2}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create Data Model query (Type=7 connection) + connection-only query - _powerQueryCommands.Create(batch, dataModelQueryName, dataModelMCode, PowerQueryLoadMode.LoadToDataModel); - _powerQueryCommands.Create(batch, connOnlyQueryName, connOnlyMCode, PowerQueryLoadMode.ConnectionOnly); - - // Act - View should work without COMException - var viewResult = _powerQueryCommands.View(batch, connOnlyQueryName); - - // Assert - Assert.True(viewResult.Success, $"View failed: {viewResult.ErrorMessage}"); - Assert.Contains("\"Y\"", viewResult.MCode); - _output.WriteLine("View succeeded in presence of Data Model connection"); - } - - /// - /// Regression test: List should work when the workbook has mixed connection types. - /// - [Fact] - public void List_WithDataModelConnection_Succeeds() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - var dataModelQueryName = "PQ_DM_" + Guid.NewGuid().ToString("N")[..8]; - var connOnlyQueryName = "PQ_CO_" + Guid.NewGuid().ToString("N")[..8]; - - var dataModelMCode = @"let Source = #table({""X""}, {{1}}) in Source"; - var connOnlyMCode = @"let Source = #table({""Y""}, {{2}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testFile); - - _powerQueryCommands.Create(batch, dataModelQueryName, dataModelMCode, PowerQueryLoadMode.LoadToDataModel); - _powerQueryCommands.Create(batch, connOnlyQueryName, connOnlyMCode, PowerQueryLoadMode.ConnectionOnly); - - // Act - var listResult = _powerQueryCommands.List(batch); - - // Assert - Assert.True(listResult.Success, $"List failed: {listResult.ErrorMessage}"); - Assert.NotNull(listResult.Queries); - var queryNames = listResult.Queries.Select(q => q.Name).ToList(); - Assert.Contains(dataModelQueryName, queryNames); - Assert.Contains(connOnlyQueryName, queryNames); - _output.WriteLine($"List returned {listResult.Queries.Count} queries in presence of Data Model connection"); - } - - /// - /// Regression test: Delete (lifecycle) should work when the workbook has - /// mixed connection types including Type=7. - /// - [Fact] - public void Delete_WithDataModelConnection_Succeeds() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - var dataModelQueryName = "PQ_DM_" + Guid.NewGuid().ToString("N")[..8]; - var targetQueryName = "PQ_Del_" + Guid.NewGuid().ToString("N")[..8]; - - var dataModelMCode = @"let Source = #table({""X""}, {{1}}) in Source"; - var targetMCode = @"let Source = #table({""Y""}, {{2}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testFile); - - _powerQueryCommands.Create(batch, dataModelQueryName, dataModelMCode, PowerQueryLoadMode.LoadToDataModel); - _powerQueryCommands.Create(batch, targetQueryName, targetMCode, PowerQueryLoadMode.ConnectionOnly); - - // Act - Delete should iterate connections without crashing on Type=7 - var deleteResult = _powerQueryCommands.Delete(batch, targetQueryName); - - // Assert - Assert.True(deleteResult.Success, $"Delete failed: {deleteResult.ErrorMessage}"); - - // Verify query is gone - var listResult = _powerQueryCommands.List(batch); - Assert.True(listResult.Success); - var queryNames = listResult.Queries!.Select(q => q.Name).ToList(); - Assert.DoesNotContain(targetQueryName, queryNames); - Assert.Contains(dataModelQueryName, queryNames); - _output.WriteLine("Delete succeeded in presence of Data Model connection"); - } - - /// - /// Regression test: Update a worksheet-loaded query when a Data Model - /// connection also exists. Tests the QueryTable + ListObject iteration paths. - /// - [Fact] - public void Update_WorksheetQuery_WithDataModelConnection_Succeeds() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - var dataModelQueryName = "PQ_DM_" + Guid.NewGuid().ToString("N")[..8]; - var worksheetQueryName = "PQ_WS_" + Guid.NewGuid().ToString("N")[..8]; - - var dataModelMCode = @"let Source = #table({""X""}, {{1}}) in Source"; - var worksheetMCode = @"let Source = #table({""Col1""}, {{10}}) in Source"; - var updatedMCode = @"let Source = #table({""Col1"", ""Col2""}, {{10, 20}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create Data Model query (Type=7 connection) - _powerQueryCommands.Create(batch, dataModelQueryName, dataModelMCode, PowerQueryLoadMode.LoadToDataModel); - - // Create worksheet-loaded query (has QueryTable/ListObject) - _powerQueryCommands.Create(batch, worksheetQueryName, worksheetMCode, PowerQueryLoadMode.LoadToTable); - - // Act - Update the worksheet query; iterates connections including Type=7 - var updateResult = _powerQueryCommands.Update(batch, worksheetQueryName, updatedMCode); - - // Assert - Assert.True(updateResult.Success, $"Update failed: {updateResult.ErrorMessage}"); - - var viewResult = _powerQueryCommands.View(batch, worksheetQueryName); - Assert.True(viewResult.Success, $"View failed: {viewResult.ErrorMessage}"); - Assert.Contains("\"Col2\"", viewResult.MCode); - _output.WriteLine("Update of worksheet query succeeded in presence of Data Model connection"); - } - - /// - /// Verifies that the workbook actually has a non-OLEDB connection (Type=7) - /// after loading a query to the Data Model. This validates our test setup - /// actually reproduces the precondition for the bug. - /// - [Fact] - public void DataModelLoad_CreatesNonOledbConnection() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - var queryName = "PQ_Verify_" + Guid.NewGuid().ToString("N")[..8]; - var mCode = @"let Source = #table({""V""}, {{1}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testFile); - - _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToDataModel); - - // Act - enumerate connection types - bool hasNonOledbConnection = false; - batch.Execute((ctx, ct) => - { - dynamic? connections = null; - try - { - connections = ctx.Book.Connections; - int count = connections.Count; - _output.WriteLine($"Total connections: {count}"); - - for (int i = 1; i <= count; i++) - { - dynamic? conn = null; - try - { - conn = connections[i]; - string name = conn.Name?.ToString() ?? "(unknown)"; - int connType = -1; - try { connType = (int)conn.Type; } catch { /* ignore */ } - - _output.WriteLine($" Connection {i}: '{name}' Type={connType}"); - - if (connType != 1) // Not OLEDB - { - hasNonOledbConnection = true; - - // Verify that accessing OLEDBConnection throws - try - { - dynamic? oledb = conn.OLEDBConnection; - _output.WriteLine($" OLEDBConnection accessible (unexpected for Type={connType})"); - if (oledb != null) ComUtilities.Release(ref oledb!); - } - catch (System.Runtime.InteropServices.COMException ex) - { - _output.WriteLine($" OLEDBConnection throws COMException 0x{ex.HResult:X8} (expected)"); - } - } - } - finally - { - ComUtilities.Release(ref conn!); - } - } - } - finally - { - ComUtilities.Release(ref connections!); - } - - return 0; - }); - - // Assert - our test setup must produce at least one non-OLEDB connection - Assert.True(hasNonOledbConnection, - "Expected at least one non-OLEDB connection (Type != 1) after LoadToDataModel. " + - "If this fails, the test setup doesn't reproduce the bug precondition."); - } -} diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.RefreshErrors.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.RefreshErrors.cs deleted file mode 100644 index c34ef05f..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.RefreshErrors.cs +++ /dev/null @@ -1,492 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands; -using Sbroenne.ExcelMcp.Core.Models; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.PowerQuery; - -/// -/// Tests for Power Query Refresh error propagation. -/// -/// BUG FIXED (Issue #399): Refresh was returning Success=true even when Power Query had formula errors. -/// -/// ROOT CAUSE: The original code used Connection.Refresh() which silently swallows errors -/// for worksheet queries (InModel=false). Only QueryTable.Refresh(false) throws actual -/// Power Query formula errors for worksheet queries. -/// -/// COM API BEHAVIOR: -/// | Connection Type | InModel | Error Thrown By | -/// |-----------------|---------|-----------------| -/// | Worksheet | false | QueryTable.Refresh(false) | -/// | Data Model | true | Connection.Refresh() | -/// -/// These tests verify that errors are now properly propagated for both connection types. -/// -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("RequiresExcel", "true")] -[Trait("Feature", "PowerQuery")] -[Trait("Speed", "Medium")] -public partial class PowerQueryCommandsTests -{ - /// - /// Regression test for Issue #399: Worksheet query with invalid M code should throw during refresh. - /// This tests the QueryTable.Refresh() path for worksheet queries (InModel=false). - /// - /// Setup: Create a valid query first (which loads successfully), then update it to have - /// invalid M code, then verify that Refresh throws the error. - /// - [Fact] - public void Refresh_WorksheetQueryWithInvalidMCode_ThrowsError() - { - // Arrange - Create a worksheet query with VALID M code first - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "BrokenWorksheetQuery"; - - // Valid M code for initial creation - var validMCode = @"let - Source = #table({""X""}, {{1}}) -in - Source"; - - // Invalid M code that will fail on refresh - var invalidMCode = @"let - Source = NonExistentFunction() -in - Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create query loaded to worksheet with VALID code (should succeed) - _powerQueryCommands.Create(batch, queryName, validMCode, PowerQueryLoadMode.LoadToTable); - batch.Save(); - - // Now UPDATE the query to have invalid M code (WITHOUT auto-refresh) - _powerQueryCommands.Update(batch, queryName, invalidMCode, refresh: false); - batch.Save(); - - // Act & Assert - Refresh should throw with the Power Query error message - var exception = Assert.ThrowsAny(() => - _powerQueryCommands.Refresh(batch, queryName, TimeSpan.FromMinutes(1))); - - // Verify error message contains Power Query error details - Assert.Contains("Expression.Error", exception.Message, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Regression test for Issue #399: Query referencing non-existent table should throw during refresh. - /// This is the exact scenario from the original bug report (Milestone_Export query). - /// - /// Setup: Create a valid query first, then update it to reference a non-existent table. - /// - [Fact] - public void Refresh_QueryReferencingNonExistentTable_ThrowsError() - { - // Arrange - Create a query that starts valid, then we'll change it - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "TableRefQuery"; - - // Valid M code for initial creation - var validMCode = @"let - Source = #table({""X""}, {{1}}) -in - Source"; - - // Invalid M code that references a table that doesn't exist in the workbook - var invalidMCode = @"let - Source = Excel.CurrentWorkbook(){[Name=""NonExistentTable""]}[Content] -in - Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create query loaded to worksheet with VALID code - _powerQueryCommands.Create(batch, queryName, validMCode, PowerQueryLoadMode.LoadToTable); - batch.Save(); - - // Update to reference non-existent table (WITHOUT auto-refresh) - _powerQueryCommands.Update(batch, queryName, invalidMCode, refresh: false); - batch.Save(); - - // Act & Assert - Refresh should throw - var exception = Assert.ThrowsAny(() => - _powerQueryCommands.Refresh(batch, queryName, TimeSpan.FromMinutes(1))); - - // Error message should indicate the table wasn't found - Assert.True( - exception.Message.Contains("Expression.Error", StringComparison.OrdinalIgnoreCase) || - exception.Message.Contains("DataSource.Error", StringComparison.OrdinalIgnoreCase) || - exception.Message.Contains("didn't find", StringComparison.OrdinalIgnoreCase), - $"Expected Power Query error but got: {exception.Message}"); - } - - /// - /// Regression test: Valid worksheet query should refresh successfully (no false positives). - /// - [Fact] - public void Refresh_ValidWorksheetQuery_Succeeds() - { - // Arrange - Create a valid worksheet query - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "ValidWorksheetQuery"; - - var validMCode = @"let - Source = #table( - {""Name"", ""Value""}, - {{""Test"", 100}, {""Data"", 200}} - ) -in - Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create query loaded to worksheet - _powerQueryCommands.Create(batch, queryName, validMCode, PowerQueryLoadMode.LoadToTable); - batch.Save(); - - // Act - Refresh should succeed - var result = _powerQueryCommands.Refresh(batch, queryName, TimeSpan.FromMinutes(1)); - - // Assert - Assert.True(result.Success, $"Refresh failed: {result.ErrorMessage}"); - Assert.False(result.HasErrors); - // Note: LoadedToSheet is populated during Create, not necessarily during Refresh - } - - /// - /// Regression test for Issue #399: Data Model query with invalid M code should throw during refresh. - /// This tests the Connection.Refresh() path for Data Model queries (InModel=true). - /// - /// Setup: Create a valid Data Model query first, then update it to have invalid M code. - /// - [Fact] - public void Refresh_DataModelQueryWithInvalidMCode_ThrowsError() - { - // Arrange - Create a Data Model query with VALID M code first - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "BrokenDataModelQuery"; - - // Valid M code for initial creation - var validMCode = @"let - Source = #table({""X""}, {{1}}) -in - Source"; - - // Invalid M code that will fail during refresh - var invalidMCode = @"let - Source = UndefinedReference -in - Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create query loaded to Data Model with VALID code (should succeed) - _powerQueryCommands.Create(batch, queryName, validMCode, PowerQueryLoadMode.LoadToDataModel); - batch.Save(); - - // Now UPDATE the query to have invalid M code (WITHOUT auto-refresh) - _powerQueryCommands.Update(batch, queryName, invalidMCode, refresh: false); - batch.Save(); - - // Act & Assert - Refresh should throw with Power Query error - var exception = Assert.ThrowsAny(() => - _powerQueryCommands.Refresh(batch, queryName, TimeSpan.FromMinutes(1))); - - // Verify error message contains Power Query error details or Data Model error - Assert.True( - exception.Message.Contains("Expression.Error", StringComparison.OrdinalIgnoreCase) || - exception.Message.Contains("Data Model", StringComparison.OrdinalIgnoreCase) || - exception.Message.Contains("couldn't get data", StringComparison.OrdinalIgnoreCase), - $"Expected Power Query/Data Model error but got: {exception.Message}"); - } - - /// - /// Regression test: Valid Data Model query should refresh successfully. - /// - [Fact] - public void Refresh_ValidDataModelQuery_Succeeds() - { - // Arrange - Create a valid Data Model query - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "ValidDataModelQuery"; - - var validMCode = @"let - Source = #table( - {""Category"", ""Amount""}, - {{""Sales"", 1000}, {""Marketing"", 500}} - ) -in - Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create query loaded to Data Model - _powerQueryCommands.Create(batch, queryName, validMCode, PowerQueryLoadMode.LoadToDataModel); - batch.Save(); - - // Act - Refresh should succeed - var result = _powerQueryCommands.Refresh(batch, queryName, TimeSpan.FromMinutes(1)); - - // Assert - Assert.True(result.Success, $"Refresh failed: {result.ErrorMessage}"); - Assert.False(result.HasErrors); - } - - /// - /// Regression test: Refresh with TimeSpan.Zero timeout should use the default 5-minute timeout - /// instead of throwing ArgumentOutOfRangeException. - /// - /// BUG: The source generator produces `args.Timeout ?? default(TimeSpan)` = TimeSpan.Zero - /// when no timeout is supplied (CLI without --timeout, MCP without timeout parameter). - /// The Core method used to throw ArgumentOutOfRangeException("Timeout must be greater than zero."). - /// FIX: TimeSpan.Zero now falls back to 5-minute default. - /// - [Fact] - public void Refresh_ZeroTimeout_UsesDefaultAndSucceeds() - { - // Arrange - Create a valid worksheet query - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "ZeroTimeoutQuery"; - - var validMCode = @"let - Source = #table({""Name""}, {{""Test""}}) -in - Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - _powerQueryCommands.Create(batch, queryName, validMCode, PowerQueryLoadMode.LoadToTable); - batch.Save(); - - // Act - Refresh with TimeSpan.Zero (the exact value generated when timeout is omitted) - var result = _powerQueryCommands.Refresh(batch, queryName, TimeSpan.Zero); - - // Assert - Should succeed, not throw ArgumentOutOfRangeException - Assert.True(result.Success, $"Refresh failed: {result.ErrorMessage}"); - Assert.False(result.HasErrors); - } - - /// - /// Regression test: Refresh with negative timeout should use the default 5-minute timeout. - /// - [Fact] - public void Refresh_NegativeTimeout_UsesDefaultAndSucceeds() - { - // Arrange - Create a valid worksheet query - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "NegativeTimeoutQuery"; - - var validMCode = @"let - Source = #table({""Name""}, {{""Test""}}) -in - Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - _powerQueryCommands.Create(batch, queryName, validMCode, PowerQueryLoadMode.LoadToTable); - batch.Save(); - - // Act - Refresh with negative timeout - var result = _powerQueryCommands.Refresh(batch, queryName, TimeSpan.FromSeconds(-1)); - - // Assert - Should succeed, not throw ArgumentOutOfRangeException - Assert.True(result.Success, $"Refresh failed: {result.ErrorMessage}"); - Assert.False(result.HasErrors); - } - - /// - /// Verifies that refresh throws when query is connection-only (no QueryTable or DataModel connection). - /// Connection-only queries have no mechanism to refresh - they're only query definitions. - /// - [Fact] - public void Refresh_ConnectionOnlyQuery_ThrowsBecauseNoRefreshMechanism() - { - // Arrange - Create a connection-only query (no worksheet table, no Data Model) - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "ConnectionOnlyQuery"; - - var validMCode = @"let - Source = #table({""X""}, {{1}}) -in - Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create connection-only query - _powerQueryCommands.Create(batch, queryName, validMCode, PowerQueryLoadMode.ConnectionOnly); - batch.Save(); - - // Act & Assert - Connection-only queries cannot be refreshed because there's - // no QueryTable (worksheet) or InModel=true connection (Data Model) to refresh - var exception = Assert.ThrowsAny(() => - _powerQueryCommands.Refresh(batch, queryName, TimeSpan.FromMinutes(1))); - - // Should indicate no refresh mechanism found - Assert.Contains("Could not find connection or table", exception.Message, StringComparison.OrdinalIgnoreCase); - } - - // ────────────────────────────────────────────────────────────────────────── - // Timeout clamping regression tests (both Refresh and RefreshAll) - // - // BUG: CLI --timeout 1800 is parsed by TimeSpan.Parse as 1800 DAYS (not - // seconds), exceeding CancellationTokenSource's max delay (~49.7 days = - // uint.MaxValue-1 ms). Both Refresh() and RefreshAll() now clamp values - // above the CTS limit instead of throwing ArgumentOutOfRangeException. - // ────────────────────────────────────────────────────────────────────────── - - [Fact] - public void Refresh_OversizedTimeout_ClampsAndSucceeds() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "OversizedTimeoutQuery"; - var mCode = @"let Source = #table({""Name""}, {{""Test""}}) in Source"; - using var batch = ExcelSession.BeginBatch(testExcelFile); - _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToTable); - batch.Save(); - - // Act - TimeSpan.Parse("1800") = 1800 days >> CTS max (~49.7 days) - var result = _powerQueryCommands.Refresh(batch, queryName, TimeSpan.FromDays(1800)); - - // Assert - Assert.True(result.Success, $"Refresh failed: {result.ErrorMessage}"); - Assert.False(result.HasErrors); - } - - [Fact] - public void Refresh_AtCtsMaxBoundary_Succeeds() - { - // Arrange - exactly uint.MaxValue-1 ms should NOT be clamped (it's the limit itself) - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "MaxBoundaryQuery"; - var mCode = @"let Source = #table({""Name""}, {{""Test""}}) in Source"; - using var batch = ExcelSession.BeginBatch(testExcelFile); - _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToTable); - batch.Save(); - - // Act - exactly at the CTS limit (should pass through without clamping) - var atLimit = TimeSpan.FromMilliseconds(uint.MaxValue - 1); - var result = _powerQueryCommands.Refresh(batch, queryName, atLimit); - - // Assert - Assert.True(result.Success, $"Refresh failed: {result.ErrorMessage}"); - Assert.False(result.HasErrors); - } - - [Fact] - public void Refresh_OneMsOverCtsMax_ClampsAndSucceeds() - { - // Arrange - uint.MaxValue ms is one ms over the CTS limit, should be clamped - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "OverBoundaryQuery"; - var mCode = @"let Source = #table({""Name""}, {{""Test""}}) in Source"; - using var batch = ExcelSession.BeginBatch(testExcelFile); - _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToTable); - batch.Save(); - - // Act - one ms over the limit - var overLimit = TimeSpan.FromMilliseconds((double)uint.MaxValue); - var result = _powerQueryCommands.Refresh(batch, queryName, overLimit); - - // Assert - Assert.True(result.Success, $"Refresh failed: {result.ErrorMessage}"); - Assert.False(result.HasErrors); - } - - [Fact] - public void RefreshAll_ZeroTimeout_UsesDefaultAndSucceeds() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "RefreshAllZeroTimeoutQuery"; - var mCode = @"let Source = #table({""Name""}, {{""Test""}}) in Source"; - using var batch = ExcelSession.BeginBatch(testExcelFile); - _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToTable); - batch.Save(); - - // Act - TimeSpan.Zero should fall back to 5-minute default - var result = _powerQueryCommands.RefreshAll(batch, TimeSpan.Zero); - - // Assert - Assert.True(result.Success, $"RefreshAll failed: {result.ErrorMessage}"); - } - - [Fact] - public void RefreshAll_NegativeTimeout_UsesDefaultAndSucceeds() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "RefreshAllNegTimeoutQuery"; - var mCode = @"let Source = #table({""Name""}, {{""Test""}}) in Source"; - using var batch = ExcelSession.BeginBatch(testExcelFile); - _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToTable); - batch.Save(); - - // Act - negative timeout should fall back to 5-minute default - var result = _powerQueryCommands.RefreshAll(batch, TimeSpan.FromSeconds(-1)); - - // Assert - Assert.True(result.Success, $"RefreshAll failed: {result.ErrorMessage}"); - } - - [Fact] - public void RefreshAll_OversizedTimeout_ClampsAndSucceeds() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "OversizedTimeoutAllQuery"; - var mCode = @"let Source = #table({""Name""}, {{""Test""}}) in Source"; - using var batch = ExcelSession.BeginBatch(testExcelFile); - _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToTable); - batch.Save(); - - // Act - 1800 days >> CTS max - var result = _powerQueryCommands.RefreshAll(batch, TimeSpan.FromDays(1800)); - - // Assert - Assert.True(result.Success, $"RefreshAll failed: {result.ErrorMessage}"); - } - - [Fact] - public void RefreshAll_AtCtsMaxBoundary_Succeeds() - { - // Arrange - exactly uint.MaxValue-1 ms should NOT be clamped - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "RefreshAllMaxBoundaryQuery"; - var mCode = @"let Source = #table({""Name""}, {{""Test""}}) in Source"; - using var batch = ExcelSession.BeginBatch(testExcelFile); - _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToTable); - batch.Save(); - - // Act - var atLimit = TimeSpan.FromMilliseconds(uint.MaxValue - 1); - var result = _powerQueryCommands.RefreshAll(batch, atLimit); - - // Assert - Assert.True(result.Success, $"RefreshAll failed: {result.ErrorMessage}"); - } - - [Fact] - public void RefreshAll_OneMsOverCtsMax_ClampsAndSucceeds() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "RefreshAllOverBoundaryQuery"; - var mCode = @"let Source = #table({""Name""}, {{""Test""}}) in Source"; - using var batch = ExcelSession.BeginBatch(testExcelFile); - _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToTable); - batch.Save(); - - // Act - var overLimit = TimeSpan.FromMilliseconds((double)uint.MaxValue); - var result = _powerQueryCommands.RefreshAll(batch, overLimit); - - // Assert - Assert.True(result.Success, $"RefreshAll failed: {result.ErrorMessage}"); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.RefreshWait.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.RefreshWait.cs deleted file mode 100644 index ab76cf9e..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.RefreshWait.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System.Reflection; -using Sbroenne.ExcelMcp.Core.Commands; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.PowerQuery; - -public partial class PowerQueryCommandsTests -{ - [Fact] - public void RefreshWait_WhenCancellationRequested_InvokesCancelActionAndThrows() - { - MethodInfo waitMethod = GetRefreshWaitMethod(); - - bool cancelCalled = false; - using var cts = new CancellationTokenSource(); - - var cancellationThread = new Thread(() => - { - Thread.Sleep(50); - cts.Cancel(); - }); - cancellationThread.Start(); - - try - { - var exception = Assert.Throws(() => - waitMethod.Invoke(null, - [ - (Func)(() => true), - (Action)(() => cancelCalled = true), - cts.Token - ])); - - Assert.IsType(exception.InnerException); - Assert.True(cancelCalled); - } - finally - { - cancellationThread.Join(); - } - } - - [Fact] - public void RefreshWait_WhenRefreshCompletes_DoesNotInvokeCancelAction() - { - MethodInfo waitMethod = GetRefreshWaitMethod(); - - int pollCount = 0; - bool cancelCalled = false; - - waitMethod.Invoke(null, - [ - (Func)(() => Interlocked.Increment(ref pollCount) == 1), - (Action)(() => cancelCalled = true), - CancellationToken.None - ]); - - Assert.True(pollCount >= 2); - Assert.False(cancelCalled); - } - - private static MethodInfo GetRefreshWaitMethod() - { - var waitMethod = typeof(PowerQueryCommands).GetMethod( - "WaitForRefreshCompletion", - BindingFlags.NonPublic | BindingFlags.Static); - - return waitMethod ?? throw new InvalidOperationException( - "Expected private method WaitForRefreshCompletion was not found."); - } -} diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.Rename.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.Rename.cs deleted file mode 100644 index baae668e..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.Rename.cs +++ /dev/null @@ -1,300 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands; -using Sbroenne.ExcelMcp.Core.Models; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.PowerQuery; - -/// -/// Integration tests for Power Query rename operations. -/// - success, content unchanged -/// - conflict detection (case-insensitive + trim) -/// - missing query failure -/// - invalid name failure (empty/whitespace) -/// - no-op (normalized names equal) -/// - case-only rename (Excel decides outcome) -/// -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("RequiresExcel", "true")] -[Trait("Feature", "PowerQuery")] -[Trait("Speed", "Medium")] -public class PowerQueryCommandsRenameTests : IClassFixture -{ - private readonly PowerQueryCommands _commands; - private readonly PowerQueryTestsFixture _fixture; - - public PowerQueryCommandsRenameTests(PowerQueryTestsFixture fixture) - { - _fixture = fixture; - var dataModelCommands = new DataModelCommands(); - _commands = new PowerQueryCommands(dataModelCommands); - } - - #region Success scenarios - - /// - /// Rename an existing query to a new unique name. - /// LLM use case: "rename query OldName to NewName" - /// - [Fact] - public void Rename_UniqueNewName_ReturnsSuccess() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - var queryName = $"PQ_Rename_{Guid.NewGuid():N}"[..20]; - var newName = $"PQ_Renamed_{Guid.NewGuid():N}"[..20]; - var mCode = "let Source = 1 in Source"; - - using var batch = ExcelSession.BeginBatch(testFile); - _commands.Create(batch, queryName, mCode, PowerQueryLoadMode.ConnectionOnly); - - // Act - var result = _commands.Rename(batch, queryName, newName); - - // Assert - Assert.True(result.Success, $"Rename failed: {result.ErrorMessage}"); - Assert.Equal("power-query", result.ObjectType); - Assert.Equal(queryName, result.OldName); - Assert.Equal(newName, result.NewName); - - // Verify query exists under new name - var list = _commands.List(batch); - Assert.Contains(list.Queries, q => q.Name == newName); - Assert.DoesNotContain(list.Queries, q => q.Name == queryName); - } - - /// - /// Verify M code content is unchanged after rename. - /// - [Fact] - public void Rename_ContentUnchanged_AfterRename() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - var queryName = $"PQ_Content_{Guid.NewGuid():N}"[..20]; - var newName = $"PQ_NewContent_{Guid.NewGuid():N}"[..20]; - var mCode = "let Source = \"OriginalContent\" in Source"; - - using var batch = ExcelSession.BeginBatch(testFile); - _commands.Create(batch, queryName, mCode, PowerQueryLoadMode.ConnectionOnly); - - // Act - var result = _commands.Rename(batch, queryName, newName); - - // Assert - Assert.True(result.Success, $"Rename failed: {result.ErrorMessage}"); - - var view = _commands.View(batch, newName); - Assert.True(view.Success); - Assert.Contains("OriginalContent", view.MCode); - } - - #endregion - - #region No-op scenarios - - /// - /// Rename where normalized names are equal returns success (no-op). - /// - [Fact] - public void Rename_TrimEqual_ReturnsNoOpSuccess() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - var queryName = "TrimTest"; - var mCode = "let Source = 1 in Source"; - - using var batch = ExcelSession.BeginBatch(testFile); - _commands.Create(batch, queryName, mCode, PowerQueryLoadMode.ConnectionOnly); - - // Act: rename with leading/trailing spaces (should trim to same name) - var result = _commands.Rename(batch, queryName, " TrimTest "); - - // Assert - Assert.True(result.Success, $"No-op rename should succeed: {result.ErrorMessage}"); - Assert.Equal("TrimTest", result.NormalizedOldName); - Assert.Equal("TrimTest", result.NormalizedNewName); - - // Query still exists - var list = _commands.List(batch); - Assert.Contains(list.Queries, q => q.Name == "TrimTest"); - } - - /// - /// Rename with identical name is no-op success. - /// - [Fact] - public void Rename_IdenticalName_ReturnsNoOpSuccess() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - var queryName = "IdenticalTest"; - var mCode = "let Source = 1 in Source"; - - using var batch = ExcelSession.BeginBatch(testFile); - _commands.Create(batch, queryName, mCode, PowerQueryLoadMode.ConnectionOnly); - - // Act - var result = _commands.Rename(batch, queryName, queryName); - - // Assert - Assert.True(result.Success, $"Identical name should be no-op success: {result.ErrorMessage}"); - } - - #endregion - - #region Case-only rename - - /// - /// Case-only rename attempts COM rename (Excel decides outcome). - /// - [Fact] - public void Rename_CaseOnlyChange_AttemptsRename() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - var queryName = "casetest"; - var newName = "CaseTest"; - var mCode = "let Source = 1 in Source"; - - using var batch = ExcelSession.BeginBatch(testFile); - _commands.Create(batch, queryName, mCode, PowerQueryLoadMode.ConnectionOnly); - - // Act - var result = _commands.Rename(batch, queryName, newName); - - // Assert: Excel may accept or reject case-only rename; either way, we have a result - // The important thing is that we attempted it (not treated as no-op) - Assert.NotNull(result); - - if (result.Success) - { - // Verify new casing appears in list - var list = _commands.List(batch); - Assert.Contains(list.Queries, q => q.Name == newName); - } - } - - #endregion - - #region Error scenarios - - /// - /// Rename to a name that conflicts (case-insensitive) with another query. - /// - [Fact] - public void Rename_ConflictingName_ReturnsError() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - var query1 = "QueryOne"; - var query2 = "QueryTwo"; - var mCode = "let Source = 1 in Source"; - - using var batch = ExcelSession.BeginBatch(testFile); - _commands.Create(batch, query1, mCode, PowerQueryLoadMode.ConnectionOnly); - _commands.Create(batch, query2, mCode, PowerQueryLoadMode.ConnectionOnly); - - // Act: try to rename query1 to query2 (conflict) - var result = _commands.Rename(batch, query1, query2); - - // Assert - Assert.False(result.Success); - Assert.Contains("already exists", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Rename to a name that conflicts case-insensitively. - /// - [Fact] - public void Rename_CaseInsensitiveConflict_ReturnsError() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - var query1 = "QueryAlpha"; - var query2 = "QueryBeta"; - var mCode = "let Source = 1 in Source"; - - using var batch = ExcelSession.BeginBatch(testFile); - _commands.Create(batch, query1, mCode, PowerQueryLoadMode.ConnectionOnly); - _commands.Create(batch, query2, mCode, PowerQueryLoadMode.ConnectionOnly); - - // Act: try to rename query1 to "querybeta" (case-insensitive conflict) - var result = _commands.Rename(batch, query1, "querybeta"); - - // Assert - Assert.False(result.Success); - Assert.Contains("already exists", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Rename a query that does not exist. - /// - [Fact] - public void Rename_MissingQuery_ReturnsError() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - var result = _commands.Rename(batch, "NonExistent", "NewName"); - - // Assert - Assert.False(result.Success); - Assert.Contains("not found", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Rename with empty new name. - /// - [Fact] - public void Rename_EmptyNewName_ReturnsError() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - var queryName = "EmptyTest"; - var mCode = "let Source = 1 in Source"; - - using var batch = ExcelSession.BeginBatch(testFile); - _commands.Create(batch, queryName, mCode, PowerQueryLoadMode.ConnectionOnly); - - // Act - var result = _commands.Rename(batch, queryName, ""); - - // Assert - Assert.False(result.Success); - Assert.Contains("empty", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Rename with whitespace-only new name. - /// - [Fact] - public void Rename_WhitespaceNewName_ReturnsError() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - var queryName = "WhitespaceTest"; - var mCode = "let Source = 1 in Source"; - - using var batch = ExcelSession.BeginBatch(testFile); - _commands.Create(batch, queryName, mCode, PowerQueryLoadMode.ConnectionOnly); - - // Act - var result = _commands.Rename(batch, queryName, " "); - - // Assert - Assert.False(result.Success); - Assert.Contains("empty", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); - } - - #endregion -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.UpdateErrors.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.UpdateErrors.cs deleted file mode 100644 index 3ef3c98c..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.UpdateErrors.cs +++ /dev/null @@ -1,121 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands; -using Sbroenne.ExcelMcp.Core.Models; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.PowerQuery; - -/// -/// Tests for Power Query Update error handling. -/// Validates that COM errors are caught and translated to meaningful messages. -/// -/// DISCOVERY: Excel accepts invalid M code during formula assignment (query.Formula = mCode). -/// The error only occurs during REFRESH when the engine actually parses the M code. -/// This means the error handling in Update catches errors during the post-update refresh, -/// not during the formula assignment itself. -/// -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("RequiresExcel", "true")] -[Trait("Feature", "PowerQuery")] -[Trait("Speed", "Medium")] -public partial class PowerQueryCommandsTests -{ - /// - /// Verifies that Excel accepts invalid M code during formula assignment. - /// The M code validation happens lazily during refresh, not during assignment. - /// This is expected Excel behavior. - /// - [Fact] - public void Update_InvalidMCodeSyntax_AcceptedByExcel() - { - // Arrange - Create a query first - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "TestQuery"; - var validMCode = @"let - Source = #table({""A""}, {{1}}) -in - Source"; - - // This M code has syntax errors but Excel will accept it during assignment - var invalidMCode = @"let - Source = this is not valid M code syntax!!! -in - Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create valid query first - _powerQueryCommands.Create(batch, queryName, validMCode, PowerQueryLoadMode.ConnectionOnly); - batch.Save(); - - // Act - Excel accepts the invalid M code (validation is lazy) - // No exception should be thrown - ConnectionOnly queries don't refresh - _powerQueryCommands.Update(batch, queryName, invalidMCode); - - // Assert - The formula was updated (even though invalid) - var result = _powerQueryCommands.View(batch, queryName); - Assert.True(result.Success, result.ErrorMessage); - Assert.Contains("not valid", result.MCode); - } - - /// - /// Verifies that Update with valid M code succeeds (regression test). - /// - [Fact] - public void Update_ValidMCode_Succeeds() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "TestQuery3"; - var initialMCode = @"let - Source = #table({""A""}, {{1}}) -in - Source"; - - var updatedMCode = @"let - Source = #table({""A"", ""B""}, {{1, 2}}) -in - Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create query - _powerQueryCommands.Create(batch, queryName, initialMCode, PowerQueryLoadMode.ConnectionOnly); - batch.Save(); - - // Act - Update with valid M code should succeed - _powerQueryCommands.Update(batch, queryName, updatedMCode); - - // Assert - View should show updated M code - var result = _powerQueryCommands.View(batch, queryName); - Assert.True(result.Success, result.ErrorMessage); - Assert.Contains("B", result.MCode); // New column added - } - - /// - /// Verifies that Update on non-existent query throws InvalidOperationException. - /// - [Fact] - public void Update_NonExistentQuery_ThrowsWithMeaningfulMessage() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "NonExistentQuery"; - var mCode = @"let Source = 1 in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Act & Assert - var exception = Assert.Throws(() => - _powerQueryCommands.Update(batch, queryName, mCode)); - - // Verify error message is meaningful - Assert.Contains(queryName, exception.Message); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.WorksheetCleanup.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.WorksheetCleanup.cs deleted file mode 100644 index 1794fc88..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.WorksheetCleanup.cs +++ /dev/null @@ -1,883 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands; -using Sbroenne.ExcelMcp.Core.Commands.Table; -using Sbroenne.ExcelMcp.Core.Models; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.PowerQuery; - -/// -/// Tests for Power Query worksheet loading cleanup operations. -/// -/// These tests validate that: -/// - LoadToTable creates properly named connections (Query - {name}) -/// - Delete of loaded query removes query, connection, AND table (clean slate) -/// - No orphaned connections remain after operations -/// -/// -/// Created to address bug: LoadQueryToWorksheet was creating orphaned connections -/// with generic names like "Connection", "Connection1" instead of "Query - {name}" -/// -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("Feature", "PowerQuery")] -[Trait("RequiresExcel", "true")] -[Trait("Speed", "Medium")] -public class PowerQueryWorksheetCleanupTests : IClassFixture -{ - private readonly PowerQueryCommands _powerQueryCommands; - private readonly DataModelCommands _dataModelCommands; - private readonly ConnectionCommands _connectionCommands; - private readonly TableCommands _tableCommands; - private readonly TempDirectoryFixture _fixture; - - public PowerQueryWorksheetCleanupTests(TempDirectoryFixture fixture) - { - _dataModelCommands = new DataModelCommands(); - _powerQueryCommands = new PowerQueryCommands(_dataModelCommands); - _connectionCommands = new ConnectionCommands(); - _tableCommands = new TableCommands(); - _fixture = fixture; - } - - #region Connection Naming Tests - Verify Add2 Fix - - /// - /// Verifies that LoadToTable creates a properly named connection following - /// the "Query - {queryName}" pattern, not a generic name like "Connection". - /// - /// This is a regression test for the bug where ListObjects.Add() was creating - /// connections with generic names instead of proper Power Query naming. - /// - [Fact] - public void Create_LoadToTable_CreatesProperlyNamedConnection() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_ProperName_" + Guid.NewGuid().ToString("N")[..8]; - var mCode = @"let Source = #table({""Value""}, {{1}, {2}, {3}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Act - Create query with LoadToTable - _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToTable); - - // Assert - Connection should follow "Query - {name}" pattern - var connections = _connectionCommands.List(batch); - Assert.True(connections.Success, $"List connections failed: {connections.ErrorMessage}"); - - // Should have exactly one connection with proper naming - var expectedConnectionName = $"Query - {queryName}"; - Assert.Contains(connections.Connections, c => c.Name == expectedConnectionName); - - // Should NOT have generic-named connections - Assert.DoesNotContain(connections.Connections, c => c.Name == "Connection"); - Assert.DoesNotContain(connections.Connections, c => c.Name == "Connection1"); - } - - /// - /// Verifies that multiple LoadToTable operations each create properly named - /// connections without any generic "Connection", "Connection1" etc. orphans. - /// - [Fact] - public void Create_MultipleLoadToTable_NoOrphanedConnections() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var suffix = Guid.NewGuid().ToString("N")[..6]; - var queryName1 = "PQ_Multi1_" + suffix; - var queryName2 = "PQ_Multi2_" + suffix; - var queryName3 = "PQ_Multi3_" + suffix; - var mCode = @"let Source = #table({""Val""}, {{1}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Act - Create multiple queries with LoadToTable - _powerQueryCommands.Create(batch, queryName1, mCode, PowerQueryLoadMode.LoadToTable, "Sheet1"); - _powerQueryCommands.Create(batch, queryName2, mCode, PowerQueryLoadMode.LoadToTable, "Sheet2"); - _powerQueryCommands.Create(batch, queryName3, mCode, PowerQueryLoadMode.LoadToTable, "Sheet3"); - - // Assert - var connections = _connectionCommands.List(batch); - Assert.True(connections.Success); - - // Should have exactly 3 properly named connections - Assert.Contains(connections.Connections, c => c.Name == $"Query - {queryName1}"); - Assert.Contains(connections.Connections, c => c.Name == $"Query - {queryName2}"); - Assert.Contains(connections.Connections, c => c.Name == $"Query - {queryName3}"); - - // Count connections - should be exactly 3 (no orphans) - var pqConnections = connections.Connections.Where(c => c.IsPowerQuery).ToList(); - Assert.Equal(3, pqConnections.Count); - - // Should NOT have any generic-named connections - Assert.DoesNotContain(connections.Connections, c => c.Name == "Connection"); - Assert.DoesNotContain(connections.Connections, c => c.Name.StartsWith("Connection", StringComparison.Ordinal) && char.IsDigit(c.Name.Last())); - } - - #endregion - - #region Worksheet Table Naming Tests - - /// - /// REGRESSION TEST: LoadToTable must name the worksheet ListObject/table after the query. - /// - /// Without the fix, Excel auto-assigns a generic name like "Table1" or - /// "Table_ExternalData_1", which breaks M-code lookups such as: - /// Excel.CurrentWorkbook(){[Name = queryName]}[Content] - /// - /// With the fix, listObject.Name = queryName is called after the QueryTable - /// refresh, ensuring the table name always matches the query name. - /// - /// Discovered while migrating CP Toolkit workbooks where - /// Excel.CurrentWorkbook(){[Name="Milestones"]}[Content] failed because - /// the table was still named "Table_ExternalData_1". - /// - [Fact] - public void Create_LoadToTable_WorksheetTableNamedAfterQuery() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_TableName_" + Guid.NewGuid().ToString("N")[..8]; - var mCode = @"let Source = #table({""Value""}, {{1}, {2}, {3}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Act - Create query with LoadToTable - _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToTable); - - // Assert - The worksheet ListObject must be named after the query - var tables = _tableCommands.List(batch); - Assert.True(tables.Success, $"List tables failed: {tables.ErrorMessage}"); - Assert.True(tables.Tables.Count > 0, "Expected at least one worksheet table after LoadToTable"); - - // Primary assertion: table is named after the query (not "Table1", "Table_ExternalData_1", etc.) - Assert.True( - tables.Tables.Any(t => t.Name == queryName), - $"Expected worksheet table named '{queryName}' but found: [{string.Join(", ", tables.Tables.Select(t => t.Name))}]"); - - // Tables with generic names should NOT exist - Assert.DoesNotContain(tables.Tables, t => t.Name.StartsWith("Table1", StringComparison.Ordinal)); - Assert.DoesNotContain(tables.Tables, t => t.Name.StartsWith("Table_ExternalData", StringComparison.Ordinal)); - } - - /// - /// Verifies that LoadTo (change destination) also names the table after the query. - /// - [Fact] - public void LoadTo_ConnectionOnlyToTable_WorksheetTableNamedAfterQuery() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_LoadToName_" + Guid.NewGuid().ToString("N")[..8]; - var mCode = @"let Source = #table({""Val""}, {{42}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create initially as connection-only - _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.ConnectionOnly); - - // Act - change destination to worksheet table - _powerQueryCommands.LoadTo(batch, queryName, PowerQueryLoadMode.LoadToTable); - _powerQueryCommands.Refresh(batch, queryName, TimeSpan.FromMinutes(5)); - - // Assert - worksheet table is named after the query - var tables = _tableCommands.List(batch); - Assert.True(tables.Success, $"List tables failed: {tables.ErrorMessage}"); - Assert.True( - tables.Tables.Any(t => t.Name == queryName), - $"Expected table '{queryName}' but found: [{string.Join(", ", tables.Tables.Select(t => t.Name))}]"); - } - - #endregion - - #region Delete Clean Slate Tests - Query + Connection + Table - - /// - /// Verifies that deleting a query loaded to worksheet results in a clean slate: - /// - Query is removed from queries list - /// - Connection is removed (no orphans) - /// - Table/ListObject is removed from worksheet - /// - [Fact] - public void Delete_LoadedToWorksheet_CleanSlate() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_CleanSlate_" + Guid.NewGuid().ToString("N")[..8]; - var mCode = @"let Source = #table({""Val""}, {{1}, {2}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create query loaded to worksheet - _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToTable); - - // Verify everything exists before delete - var queriesBefore = _powerQueryCommands.List(batch); - Assert.Contains(queriesBefore.Queries, q => q.Name == queryName); - - var connectionsBefore = _connectionCommands.List(batch); - Assert.Contains(connectionsBefore.Connections, c => c.Name == $"Query - {queryName}"); - - var tablesBefore = _tableCommands.List(batch); - // Table name typically matches query name - Assert.True(tablesBefore.Success); - var tableCountBefore = tablesBefore.Tables.Count; - Assert.True(tableCountBefore > 0, "Expected at least one table after LoadToTable"); - - // Act - Delete the query - _powerQueryCommands.Delete(batch, queryName); - - // Assert - CLEAN SLATE - - // 1. Query is gone - var queriesAfter = _powerQueryCommands.List(batch); - Assert.True(queriesAfter.Success); - Assert.DoesNotContain(queriesAfter.Queries, q => q.Name == queryName); - - // 2. Connection is gone (no orphans) - var connectionsAfter = _connectionCommands.List(batch); - Assert.True(connectionsAfter.Success); - Assert.DoesNotContain(connectionsAfter.Connections, c => c.Name == $"Query - {queryName}"); - Assert.DoesNotContain(connectionsAfter.Connections, c => c.Name == "Connection"); - Assert.DoesNotContain(connectionsAfter.Connections, c => c.Name.StartsWith("Connection", StringComparison.Ordinal) && char.IsDigit(c.Name.Last())); - - // 3. No Power Query connections remain (clean workbook) - var pqConnections = connectionsAfter.Connections.Where(c => c.IsPowerQuery).ToList(); - Assert.Empty(pqConnections); - } - - /// - /// Verifies that deleting one of multiple loaded queries only removes that query's - /// connection and table, leaving others intact. - /// - [Fact] - public void Delete_OneOfMultipleLoadedQueries_OnlyRemovesItsOwnResources() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var suffix = Guid.NewGuid().ToString("N")[..6]; - var queryToDelete = "PQ_Delete_" + suffix; - var queryToKeep = "PQ_Keep_" + suffix; - var mCode = @"let Source = #table({""Val""}, {{1}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create two queries loaded to worksheet - _powerQueryCommands.Create(batch, queryToDelete, mCode, PowerQueryLoadMode.LoadToTable, "Sheet1"); - _powerQueryCommands.Create(batch, queryToKeep, mCode, PowerQueryLoadMode.LoadToTable, "Sheet2"); - - // Verify both exist - var queriesBefore = _powerQueryCommands.List(batch); - Assert.Contains(queriesBefore.Queries, q => q.Name == queryToDelete); - Assert.Contains(queriesBefore.Queries, q => q.Name == queryToKeep); - - var connectionsBefore = _connectionCommands.List(batch); - Assert.Contains(connectionsBefore.Connections, c => c.Name == $"Query - {queryToDelete}"); - Assert.Contains(connectionsBefore.Connections, c => c.Name == $"Query - {queryToKeep}"); - - // Act - Delete only one query - _powerQueryCommands.Delete(batch, queryToDelete); - - // Assert - - // 1. Deleted query is gone, kept query remains - var queriesAfter = _powerQueryCommands.List(batch); - Assert.DoesNotContain(queriesAfter.Queries, q => q.Name == queryToDelete); - Assert.Contains(queriesAfter.Queries, q => q.Name == queryToKeep); - - // 2. Deleted query's connection is gone, kept query's connection remains - var connectionsAfter = _connectionCommands.List(batch); - Assert.DoesNotContain(connectionsAfter.Connections, c => c.Name == $"Query - {queryToDelete}"); - Assert.Contains(connectionsAfter.Connections, c => c.Name == $"Query - {queryToKeep}"); - - // 3. No orphaned connections - Assert.DoesNotContain(connectionsAfter.Connections, c => c.Name == "Connection"); - - // 4. Exactly 1 Power Query connection remains - var pqConnections = connectionsAfter.Connections.Where(c => c.IsPowerQuery).ToList(); - Assert.Single(pqConnections); - } - - /// - /// Verifies clean slate when deleting a ConnectionOnly query (no table involved). - /// - [Fact] - public void Delete_ConnectionOnly_CleanSlate() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_ConnOnly_" + Guid.NewGuid().ToString("N")[..8]; - var mCode = @"let Source = #table({""Val""}, {{1}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create ConnectionOnly query (no worksheet loading) - _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.ConnectionOnly); - - // Verify query exists - var queriesBefore = _powerQueryCommands.List(batch); - Assert.Contains(queriesBefore.Queries, q => q.Name == queryName); - - // ConnectionOnly may or may not create a connection depending on implementation - // The key is that after delete, there are no orphans - - // Act - _powerQueryCommands.Delete(batch, queryName); - - // Assert - Clean slate - var queriesAfter = _powerQueryCommands.List(batch); - Assert.DoesNotContain(queriesAfter.Queries, q => q.Name == queryName); - - var connectionsAfter = _connectionCommands.List(batch); - Assert.DoesNotContain(connectionsAfter.Connections, c => c.Name.Contains(queryName)); - Assert.DoesNotContain(connectionsAfter.Connections, c => c.Name == "Connection"); - } - - #endregion - - #region Unload Clean Slate Tests - - /// - /// Verifies that unloading a query from worksheet removes table and connection - /// but keeps the query definition. - /// - [Fact] - public void Unload_LoadedToWorksheet_RemovesTableAndConnectionKeepsQuery() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_UnloadWS_" + Guid.NewGuid().ToString("N")[..8]; - var mCode = @"let Source = #table({""Val""}, {{1}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create query loaded to worksheet - _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToTable); - - // Verify connection exists - var connectionsBefore = _connectionCommands.List(batch); - Assert.Contains(connectionsBefore.Connections, c => c.Name == $"Query - {queryName}"); - - // Act - Unload - var unloadResult = _powerQueryCommands.Unload(batch, queryName); - Assert.True(unloadResult.Success, $"Unload failed: {unloadResult.ErrorMessage}"); - - // Assert - - // 1. Query still exists - var queriesAfter = _powerQueryCommands.List(batch); - Assert.Contains(queriesAfter.Queries, q => q.Name == queryName); - - // 2. Query is now ConnectionOnly - var loadConfig = _powerQueryCommands.GetLoadConfig(batch, queryName); - Assert.True(loadConfig.Success); - Assert.Equal(PowerQueryLoadMode.ConnectionOnly, loadConfig.LoadMode); - - // 3. Connection is removed (no active load = no connection needed) - var connectionsAfter = _connectionCommands.List(batch); - Assert.DoesNotContain(connectionsAfter.Connections, c => c.Name == $"Query - {queryName}"); - - // 4. No orphaned connections - Assert.DoesNotContain(connectionsAfter.Connections, c => c.Name == "Connection"); - } - - #endregion - - #region Edge Cases - - /// - /// Verifies that creating and immediately deleting a query leaves no traces. - /// - [Fact] - public void CreateThenDelete_LoadToTable_NoTraces() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_CreateDelete_" + Guid.NewGuid().ToString("N")[..8]; - var mCode = @"let Source = #table({""Val""}, {{1}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Act - Create and immediately delete - _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToTable); - _powerQueryCommands.Delete(batch, queryName); - - // Assert - Completely clean workbook - var queries = _powerQueryCommands.List(batch); - Assert.Empty(queries.Queries); - - var connections = _connectionCommands.List(batch); - Assert.Empty(connections.Connections); - - var tables = _tableCommands.List(batch); - Assert.Empty(tables.Tables); - } - - /// - /// Verifies that the original bug test case (that would have created orphaned - /// connections) now works correctly with proper connection naming. - /// - [Fact] - public void Delete_ExistingQuery_VerifiesCleanSlate() - { - // This is the improved version of the original Delete_ExistingQuery_ReturnsSuccess test - // that actually verifies cleanup, not just success - - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_Delete_" + Guid.NewGuid().ToString("N")[..8]; - var mCode = @"let - Source = #table( - {""Column1"", ""Column2"", ""Column3""}, - { - {""Value1"", ""Value2"", ""Value3""}, - {""A"", ""B"", ""C""}, - {""X"", ""Y"", ""Z""} - } - ) -in - Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Act - _powerQueryCommands.Create(batch, queryName, mCode); // Default is LoadToTable - _powerQueryCommands.Delete(batch, queryName); - - // Assert - CLEAN SLATE (not just "reaching here means success") - var queries = _powerQueryCommands.List(batch); - Assert.DoesNotContain(queries.Queries, q => q.Name == queryName); - - var connections = _connectionCommands.List(batch); - Assert.DoesNotContain(connections.Connections, c => c.Name == $"Query - {queryName}"); - Assert.DoesNotContain(connections.Connections, c => c.Name == "Connection"); - Assert.DoesNotContain(connections.Connections, c => c.IsPowerQuery); - } - - /// - /// Verifies LoadTo operation on an existing ConnectionOnly query creates proper connection. - /// Scenario: Create as ConnectionOnly → LoadTo Table → verify proper naming. - /// - [Fact] - public void LoadTo_ExistingConnectionOnlyQuery_CreatesProperlyNamedConnection() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_LoadToExisting_" + Guid.NewGuid().ToString("N")[..8]; - var mCode = @"let Source = #table({""Val""}, {{1}, {2}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create as ConnectionOnly first - _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.ConnectionOnly); - - // Verify no connections initially - var connsBefore = _connectionCommands.List(batch); - Assert.DoesNotContain(connsBefore.Connections, c => c.IsPowerQuery); - - // Act - LoadTo Table - _powerQueryCommands.LoadTo(batch, queryName, PowerQueryLoadMode.LoadToTable, "Sheet1"); - - // Assert - Connection should be properly named - var connsAfter = _connectionCommands.List(batch); - Assert.Contains(connsAfter.Connections, c => c.Name == $"Query - {queryName}"); - Assert.DoesNotContain(connsAfter.Connections, c => c.Name == "Connection"); - - // Cleanup works - _powerQueryCommands.Delete(batch, queryName); - var connsFinal = _connectionCommands.List(batch); - Assert.Empty(connsFinal.Connections); - } - - /// - /// Verifies Refresh operation maintains proper connection naming and doesn't create orphans. - /// - [Fact] - public void Refresh_LoadedQuery_MaintainsProperConnectionNaming() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_Refresh_" + Guid.NewGuid().ToString("N")[..8]; - var mCode = @"let Source = #table({""Val""}, {{1}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create query with LoadToTable - _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToTable); - - var connsBefore = _connectionCommands.List(batch); - var connectionCountBefore = connsBefore.Connections.Count; - - // Act - Refresh the query - _powerQueryCommands.Refresh(batch, queryName, TimeSpan.FromMinutes(2)); - - // Assert - Same connection count (no new orphans) - var connsAfter = _connectionCommands.List(batch); - Assert.Equal(connectionCountBefore, connsAfter.Connections.Count); - Assert.Contains(connsAfter.Connections, c => c.Name == $"Query - {queryName}"); - Assert.DoesNotContain(connsAfter.Connections, c => c.Name == "Connection"); - } - - /// - /// Verifies Update operation maintains proper connection naming and doesn't create orphans. - /// - [Fact] - public void Update_LoadedQuery_MaintainsProperConnectionNaming() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_Update_" + Guid.NewGuid().ToString("N")[..8]; - var mCode1 = @"let Source = #table({""Val""}, {{1}}) in Source"; - var mCode2 = @"let Source = #table({""NewVal""}, {{2}, {3}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create query with LoadToTable - _powerQueryCommands.Create(batch, queryName, mCode1, PowerQueryLoadMode.LoadToTable); - - var connsBefore = _connectionCommands.List(batch); - var connectionCountBefore = connsBefore.Connections.Count; - - // Act - Update the query's M code - _powerQueryCommands.Update(batch, queryName, mCode2); - - // Assert - Same connection count (no new orphans), proper naming - var connsAfter = _connectionCommands.List(batch); - Assert.Equal(connectionCountBefore, connsAfter.Connections.Count); - Assert.Contains(connsAfter.Connections, c => c.Name == $"Query - {queryName}"); - Assert.DoesNotContain(connsAfter.Connections, c => c.Name == "Connection"); - - // Cleanup still works - _powerQueryCommands.Delete(batch, queryName); - var connsFinal = _connectionCommands.List(batch); - Assert.DoesNotContain(connsFinal.Connections, c => c.IsPowerQuery); - } - - /// - /// Verifies that mode transition from ConnectionOnly to LoadToBoth creates proper dual connections. - /// - [Fact] - public async Task LoadTo_ConnectionOnlyToBoth_CreatesDualConnectionsProperly() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_ConnToBoth_" + Guid.NewGuid().ToString("N")[..8]; - var mCode = @"let Source = #table({""Val""}, {{42}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create as ConnectionOnly - _ = _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.ConnectionOnly); - - // Verify no Power Query connections - var connsBefore = _connectionCommands.List(batch); - Assert.DoesNotContain(connsBefore.Connections, c => c.IsPowerQuery); - - // Act - LoadTo Both - _ = _powerQueryCommands.LoadTo(batch, queryName, PowerQueryLoadMode.LoadToBoth, "BothSheet"); - - // Assert - Should have TWO connections with proper naming - var connsAfter = _connectionCommands.List(batch); - var pqConns = connsAfter.Connections.Where(c => c.IsPowerQuery).ToList(); - - // Should have exactly 2 Power Query connections - Assert.Equal(2, pqConns.Count); - - // One for worksheet, one for Data Model - Assert.Contains(pqConns, c => c.Name == $"Query - {queryName}"); - Assert.Contains(pqConns, c => c.Name == $"Query - {queryName} (Data Model)"); - - // No orphans - Assert.DoesNotContain(connsAfter.Connections, c => c.Name == "Connection"); - - // Data Model table should exist - var tables = await _dataModelCommands.ListTables(batch); - Assert.Contains(tables.Tables, t => t.Name == queryName); - - // Cleanup - _ = _powerQueryCommands.Delete(batch, queryName); - var connsFinal = _connectionCommands.List(batch); - Assert.DoesNotContain(connsFinal.Connections, c => c.IsPowerQuery); - } - - /// - /// Verifies LoadToBoth creates exactly 2 connections and both are properly cleaned up. - /// - [Fact] - public async Task Create_LoadToBoth_ExactlyTwoConnectionsWithProperNaming() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_BothDual_" + Guid.NewGuid().ToString("N")[..8]; - var mCode = @"let Source = #table({""Val""}, {{1}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Act - _ = _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToBoth, "Sheet1"); - - // Assert - Exactly 2 Power Query connections - var connections = _connectionCommands.List(batch); - var pqConns = connections.Connections.Where(c => c.IsPowerQuery).ToList(); - - Assert.Equal(2, pqConns.Count); - - // Verify exact naming pattern - Assert.Contains(pqConns, c => c.Name == $"Query - {queryName}"); - Assert.Contains(pqConns, c => c.Name == $"Query - {queryName} (Data Model)"); - - // Verify worksheet table exists - var tables = _tableCommands.List(batch); - Assert.True(tables.Tables.Count > 0); - - // Verify Data Model table exists - var dmTables = await _dataModelCommands.ListTables(batch); - Assert.Contains(dmTables.Tables, t => t.Name == queryName); - - // Cleanup removes both - _ = _powerQueryCommands.Delete(batch, queryName); - var connsFinal = _connectionCommands.List(batch); - Assert.DoesNotContain(connsFinal.Connections, c => c.IsPowerQuery); - } - - /// - /// Verifies Unload then re-LoadTo works correctly without creating orphans. - /// Scenario: Create LoadToTable → Unload → LoadTo Table again - /// - [Fact] - public void UnloadThenReload_NoOrphanedConnections() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_ReloadTest_" + Guid.NewGuid().ToString("N")[..8]; - var mCode = @"let Source = #table({""Val""}, {{1}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create with LoadToTable - _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToTable); - - var connsAfterCreate = _connectionCommands.List(batch); - Assert.Single(connsAfterCreate.Connections, c => c.IsPowerQuery); - - // Unload - _powerQueryCommands.Unload(batch, queryName); - - var connsAfterUnload = _connectionCommands.List(batch); - Assert.DoesNotContain(connsAfterUnload.Connections, c => c.IsPowerQuery); - - // Re-LoadTo Table - _powerQueryCommands.LoadTo(batch, queryName, PowerQueryLoadMode.LoadToTable, "NewSheet"); - - // Assert - Should have exactly 1 properly named connection, no orphans - var connsAfterReload = _connectionCommands.List(batch); - var pqConns = connsAfterReload.Connections.Where(c => c.IsPowerQuery).ToList(); - - Assert.Single(pqConns); - Assert.Equal($"Query - {queryName}", pqConns[0].Name); - Assert.DoesNotContain(connsAfterReload.Connections, c => c.Name == "Connection"); - - // Cleanup - _powerQueryCommands.Delete(batch, queryName); - var connsFinal = _connectionCommands.List(batch); - Assert.DoesNotContain(connsFinal.Connections, c => c.IsPowerQuery); - } - - #endregion - - #region State Transition Tests - Loaded → Other - - /// - /// Regression test for Bug #2: LoadTo(ConnectionOnly) on an already-loaded query was a no-op. - /// Scenario: Create as LoadToTable → LoadTo(ConnectionOnly) - /// Expected: ListObject removed, connection removed, GetLoadConfig returns ConnectionOnly. - /// - [Fact] - public void LoadTo_LoadedToTable_ThenConnectionOnly_RemovesTableAndConnection() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_WsToConn_" + Guid.NewGuid().ToString("N")[..8]; - var mCode = @"let Source = #table({""Val""}, {{1}, {2}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create as LoadToTable first - _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToTable); - - // Verify baseline: has worksheet table and connection - var connsBefore = _connectionCommands.List(batch); - Assert.Contains(connsBefore.Connections, c => c.Name == $"Query - {queryName}"); - var tablesBefore = _tableCommands.List(batch); - Assert.True(tablesBefore.Tables.Count > 0, "Expected a table after LoadToTable"); - var configBefore = _powerQueryCommands.GetLoadConfig(batch, queryName); - Assert.Equal(PowerQueryLoadMode.LoadToTable, configBefore.LoadMode); - - // Act - transition to ConnectionOnly - var result = _powerQueryCommands.LoadTo(batch, queryName, PowerQueryLoadMode.ConnectionOnly); - Assert.True(result.Success, $"LoadTo failed: {result.ErrorMessage}"); - - // Assert: table removed - var tablesAfter = _tableCommands.List(batch); - Assert.Empty(tablesAfter.Tables); - - // Assert: connection removed - var connsAfter = _connectionCommands.List(batch); - Assert.DoesNotContain(connsAfter.Connections, c => c.Name == $"Query - {queryName}"); - Assert.DoesNotContain(connsAfter.Connections, c => c.IsPowerQuery); - - // Assert: load mode is now ConnectionOnly - var configAfter = _powerQueryCommands.GetLoadConfig(batch, queryName); - Assert.True(configAfter.Success, $"GetLoadConfig failed: {configAfter.ErrorMessage}"); - Assert.Equal(PowerQueryLoadMode.ConnectionOnly, configAfter.LoadMode); - - // Assert: query still exists - var queries = _powerQueryCommands.List(batch); - Assert.Contains(queries.Queries, q => q.Name == queryName); - } - - /// - /// State transition: LoadToTable → LoadToDataModel. - /// Expected: old ListObject + worksheet connection removed; Data Model connection added. - /// - [Fact] - public async Task LoadTo_LoadedToTable_ThenLoadToDataModel_RemovesTableAddsDataModel() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_WsToDm_" + Guid.NewGuid().ToString("N")[..8]; - var mCode = @"let Source = #table({""Val""}, {{1}, {2}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create as LoadToTable - _ = _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToTable); - - var tablesBefore = _tableCommands.List(batch); - Assert.True(tablesBefore.Tables.Count > 0, "Expected a table after LoadToTable"); - - // Act - transition to DataModel - var result = _powerQueryCommands.LoadTo(batch, queryName, PowerQueryLoadMode.LoadToDataModel); - Assert.True(result.Success, $"LoadTo(DataModel) failed: {result.ErrorMessage}"); - - // Assert: worksheet table removed (no orphaned ListObject) - var tablesAfter = _tableCommands.List(batch); - Assert.Empty(tablesAfter.Tables); - - // Assert: exactly 1 PQ connection (old worksheet connection replaced by DM connection, same name) - // Note: both worksheet and Data Model connections are named "Query - {queryName}"; - // the proof of correct mode is: no ListObject + DM table present + exactly 1 PQ connection (not 2) - var connsAfter = _connectionCommands.List(batch); - var pqConns = connsAfter.Connections.Where(c => c.IsPowerQuery).ToList(); - Assert.Single(pqConns); - Assert.Equal($"Query - {queryName}", pqConns[0].Name); - - // Assert: Data Model table present - var dmTablesAfter = await _dataModelCommands.ListTables(batch); - Assert.Contains(dmTablesAfter.Tables, t => t.Name == queryName); - } - - /// - /// State transition: LoadToDataModel → LoadToTable. - /// Expected: old Data Model connection removed; worksheet ListObject + connection added. - /// - [Fact] - public async Task LoadTo_LoadedToDataModel_ThenLoadToTable_RemovesDataModelAddsTable() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_DmToWs_" + Guid.NewGuid().ToString("N")[..8]; - var mCode = @"let Source = #table({""Val""}, {{1}, {2}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create as LoadToDataModel - _ = _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToDataModel); - - // Verify: Data Model table exists, no worksheet table - var dmTablesBefore = await _dataModelCommands.ListTables(batch); - Assert.Contains(dmTablesBefore.Tables, t => t.Name == queryName); - var tablesBefore = _tableCommands.List(batch); - Assert.Empty(tablesBefore.Tables); - - // Act - transition to LoadToTable - var result = _powerQueryCommands.LoadTo(batch, queryName, PowerQueryLoadMode.LoadToTable, queryName); - Assert.True(result.Success, $"LoadTo(Table) failed: {result.ErrorMessage}"); - - // Assert: worksheet table now exists - var tablesAfter = _tableCommands.List(batch); - Assert.True(tablesAfter.Tables.Count > 0, "Expected a worksheet table after LoadToTable"); - - // Assert: worksheet connection present and properly named - var connsAfter = _connectionCommands.List(batch); - Assert.Contains(connsAfter.Connections, c => c.Name == $"Query - {queryName}"); - - // Assert: Data Model connection removed (no orphaned DM connection) - var pqConns = connsAfter.Connections.Where(c => c.IsPowerQuery).ToList(); - Assert.Single(pqConns); // exactly 1 (worksheet only, not DM) - Assert.Equal($"Query - {queryName}", pqConns[0].Name); - - // Assert: Data Model table removed - var dmTablesAfter = await _dataModelCommands.ListTables(batch); - Assert.DoesNotContain(dmTablesAfter.Tables, t => t.Name == queryName); - } - - /// - /// State transition: LoadToBoth → ConnectionOnly. - /// Expected: both ListObject and Data Model connection removed; clean slate. - /// - [Fact] - public async Task LoadTo_LoadedToBoth_ThenConnectionOnly_RemovesBothDestinations() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "PQ_BothToConn_" + Guid.NewGuid().ToString("N")[..8]; - var mCode = @"let Source = #table({""Val""}, {{1}, {2}}) in Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create as LoadToBoth - _ = _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.LoadToBoth, queryName); - - // Verify baseline: worksheet table + 2 PQ connections + DM table - var tablesBefore = _tableCommands.List(batch); - Assert.True(tablesBefore.Tables.Count > 0, "Expected worksheet table after LoadToBoth"); - var connsBefore = _connectionCommands.List(batch); - Assert.Equal(2, connsBefore.Connections.Count(c => c.IsPowerQuery)); - var dmTablesBefore = await _dataModelCommands.ListTables(batch); - Assert.Contains(dmTablesBefore.Tables, t => t.Name == queryName); - - // Act - transition to ConnectionOnly - var result = _powerQueryCommands.LoadTo(batch, queryName, PowerQueryLoadMode.ConnectionOnly); - Assert.True(result.Success, $"LoadTo(ConnectionOnly) failed: {result.ErrorMessage}"); - - // Assert: worksheet table removed - var tablesAfter = _tableCommands.List(batch); - Assert.Empty(tablesAfter.Tables); - - // Assert: all PQ connections removed - var connsAfter = _connectionCommands.List(batch); - Assert.DoesNotContain(connsAfter.Connections, c => c.IsPowerQuery); - - // Assert: Data Model table removed - var dmTablesAfter = await _dataModelCommands.ListTables(batch); - Assert.DoesNotContain(dmTablesAfter.Tables, t => t.Name == queryName); - - // Assert: load mode is ConnectionOnly - var configAfter = _powerQueryCommands.GetLoadConfig(batch, queryName); - Assert.True(configAfter.Success, $"GetLoadConfig failed: {configAfter.ErrorMessage}"); - Assert.Equal(PowerQueryLoadMode.ConnectionOnly, configAfter.LoadMode); - - // Assert: query still exists - var queries = _powerQueryCommands.List(batch); - Assert.Contains(queries.Queries, q => q.Name == queryName); - } - - #endregion -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.cs deleted file mode 100644 index 915e0b1f..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryCommandsTests.cs +++ /dev/null @@ -1,800 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands; -using Sbroenne.ExcelMcp.Core.Commands.Range; -using Sbroenne.ExcelMcp.Core.Models; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.PowerQuery; - -/// -/// Integration tests for Power Query operations focusing on LLM use cases. -/// Tests cover the essential workflows: import, list, view, update, delete, refresh with load destinations. -/// Uses PowerQueryTestsFixture which creates ONE Power Query file per test class. -/// Tests that modify queries use CreateTestFile() for isolation. -/// -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("RequiresExcel", "true")] -[Trait("Feature", "PowerQuery")] -[Trait("Speed", "Medium")] -[Collection("Sequential")] -public partial class PowerQueryCommandsTests : IClassFixture -{ - private readonly PowerQueryCommands _powerQueryCommands; - private readonly SheetCommands _sheetCommands; - private readonly string _powerQueryFile; - private readonly PowerQueryCreationResult _creationResult; - private readonly PowerQueryTestsFixture _fixture; - - /// - /// Initializes a new instance of the class. - /// - public PowerQueryCommandsTests(PowerQueryTestsFixture fixture) - { - _fixture = fixture; - var dataModelCommands = new DataModelCommands(); - _powerQueryCommands = new PowerQueryCommands(dataModelCommands); - _sheetCommands = new SheetCommands(); - _powerQueryFile = fixture.TestFilePath; - _creationResult = fixture.CreationResult; - } - - #region Core Lifecycle Tests (6 tests) - - /// - /// Validates that the fixture creation succeeded (import operation). - /// LLM use case: "import a Power Query from a .pq file" - /// - [Fact] - public void Import_ViaFixture_CreatesQueriesSuccessfully() - { - // Assert the fixture creation succeeded - Assert.True(_creationResult.Success, - $"Power Query creation failed during fixture initialization: {_creationResult.ErrorMessage}"); - - Assert.True(_creationResult.FileCreated, "File creation failed"); - Assert.Equal(3, _creationResult.MCodeFilesCreated); - Assert.Equal(3, _creationResult.QueriesImported); - Assert.True(_creationResult.CreationTimeSeconds > 0); - } - - /// - /// Tests basic import operation with M code file. - /// LLM use case: "import this M code as a new Power Query" - /// - [Fact] - public void Import_ValidMCode_ReturnsSuccess() - { - // Arrange - Use unique file to avoid polluting fixture - var testExcelFile = _fixture.CreateTestFile(); - var queryName = "TestQuery"; - var mCode = @"let - Source = #table( - {""Column1"", ""Column2"", ""Column3""}, - { - {""Value1"", ""Value2"", ""Value3""}, - {""A"", ""B"", ""C""}, - {""X"", ""Y"", ""Z""} - } - ) -in - Source"; - - // Act - using var batch = ExcelSession.BeginBatch(testExcelFile); - _powerQueryCommands.Create(batch, queryName, mCode, PowerQueryLoadMode.ConnectionOnly); - - // Assert - // Create throws on error, so reaching here means success - var verifyResult = _powerQueryCommands.List(batch); - Assert.Contains(verifyResult.Queries, q => q.Name == queryName); - } - - /// - /// Tests listing queries in a workbook. - /// LLM use case: "show me all Power Queries in this workbook" - /// - [Fact] - public void List_FixtureWorkbook_ReturnsFixtureQueries() - { - // Act - using var batch = ExcelSession.BeginBatch(_powerQueryFile); - var result = _powerQueryCommands.List(batch); - - // Assert - Assert.True(result.Success, $"Expected success but got error: {result.ErrorMessage}"); - Assert.NotNull(result.Queries); - Assert.Equal(3, result.Queries.Count); - } - - /// - /// Tests viewing M code of an existing query. - /// LLM use case: "show me the M code for this query" - /// - [Fact] - public void View_BasicQuery_ReturnsMCode() - { - // Act - using var batch = ExcelSession.BeginBatch(_powerQueryFile); - var result = _powerQueryCommands.View(batch, "BasicQuery"); - - // Assert - Assert.True(result.Success); - Assert.NotNull(result.MCode); - Assert.Contains("Source", result.MCode); - } - - /// - /// Tests updating existing query with new M code. - /// LLM use case: "update this query's M code" - /// - [Fact] - public void Update_ExistingQuery_ReturnsSuccess() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - - var queryName = "PQ_Update_" + Guid.NewGuid().ToString("N")[..8]; - var originalMCode = @"let - Source = #table( - {""Column1"", ""Column2"", ""Column3""}, - { - {""Value1"", ""Value2"", ""Value3""}, - {""A"", ""B"", ""C""}, - {""X"", ""Y"", ""Z""} - } - ) -in - Source"; - var updatedMCode = @"let - UpdatedSource = 1 -in - UpdatedSource"; - - // Act - using var batch = ExcelSession.BeginBatch(testExcelFile); - _powerQueryCommands.Create(batch, queryName, originalMCode); - _powerQueryCommands.Update(batch, queryName, updatedMCode); - - // Assert - // Update throws on error, so reaching here means success - } - - /// - /// REGRESSION TEST for bug report: Update action merges instead of replaces M code - /// - /// Bug: Update was concatenating/merging new M code with existing M code instead of replacing it, - /// resulting in severely corrupted Power Query definitions with triple-merged comments, multiple let blocks, - /// and invalid M syntax. - /// - /// This test validates that Update completely REPLACES M code (not merges/appends). - /// LLM use case: "update this query's M code and verify it was replaced" - /// - [Fact] - public void Update_ExistingQuery_ReplacesNotMergesMCode() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - - var queryName = "PQ_ReplaceTest_" + Guid.NewGuid().ToString("N")[..8]; - - // Original M code with distinctive markers - var originalMCode = @"let - OriginalSource = ""ORIGINAL_MARKER"", - OriginalStep = ""Should be completely removed"" -in - OriginalSource"; - - // New M code that should completely replace original - var newMCode = @"let - NewSource = ""NEW_MARKER"", - NewStep = ""Should be the only content"" -in - NewSource"; - - // Act - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Step 1: Create query with original M code - _powerQueryCommands.Create(batch, queryName, originalMCode); // Create throws on error - - // Step 2: Update with new M code - _powerQueryCommands.Update(batch, queryName, newMCode); // Update throws on error - - // Step 3: View the resulting M code - var viewResult = _powerQueryCommands.View(batch, queryName); - Assert.True(viewResult.Success, $"View failed: {viewResult.ErrorMessage}"); - - // Assert - CRITICAL: Verify M code was REPLACED, not merged - // 1. Should contain the new M code - Assert.Contains("NEW_MARKER", viewResult.MCode); - Assert.Contains("NewSource", viewResult.MCode); - Assert.Contains("Should be the only content", viewResult.MCode); - - // 2. Should NOT contain any traces of the original M code - Assert.DoesNotContain("ORIGINAL_MARKER", viewResult.MCode); - Assert.DoesNotContain("OriginalSource", viewResult.MCode); - Assert.DoesNotContain("Should be completely removed", viewResult.MCode); - - // 3. Should not have duplicate 'let' or 'in' keywords (sign of merging) - int letCount = System.Text.RegularExpressions.Regex.Matches(viewResult.MCode, @"\blet\b").Count; - int inCount = System.Text.RegularExpressions.Regex.Matches(viewResult.MCode, @"\bin\b").Count; - Assert.Equal(1, letCount); - Assert.Equal(1, inCount); - } - - /// - /// REGRESSION TEST: Multiple sequential updates should each completely replace M code - /// - /// This test validates that the merging bug doesn't compound with multiple updates. - /// LLM use case: "update this query multiple times during development" - /// - [Fact] - public void Update_MultipleSequentialUpdates_EachReplacesCompletely() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - - var queryName = "PQ_MultiUpdate_" + Guid.NewGuid().ToString("N")[..8]; - - // Create three different M code versions - var version1MCode = @"let - V1 = ""VERSION_1"" -in - V1"; - - var version2MCode = @"let - V2 = ""VERSION_2"" -in - V2"; - - var version3MCode = @"let - V3 = ""VERSION_3"" -in - V3"; - - // Act - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Create with version 1 - _powerQueryCommands.Create(batch, queryName, version1MCode); // Create throws on error - - // Update to version 2 - _powerQueryCommands.Update(batch, queryName, version2MCode); // Update throws on error - - // Update to version 3 - _powerQueryCommands.Update(batch, queryName, version3MCode); // Update throws on error - - // View final result - var viewResult = _powerQueryCommands.View(batch, queryName); - // View throws on error, so reaching here means success - - // Assert - Should only have version 3, no traces of v1 or v2 - Assert.Contains("VERSION_3", viewResult.MCode); - Assert.DoesNotContain("VERSION_1", viewResult.MCode); - Assert.DoesNotContain("VERSION_2", viewResult.MCode); - - // Verify no compound merging (should still have exactly 1 let/in) - int letCount = System.Text.RegularExpressions.Regex.Matches(viewResult.MCode, @"\blet\b").Count; - int inCount = System.Text.RegularExpressions.Regex.Matches(viewResult.MCode, @"\bin\b").Count; - Assert.Equal(1, letCount); - Assert.Equal(1, inCount); - } - - /// - /// Tests deleting an existing query. - /// LLM use case: "delete this Power Query" - /// - [Fact] - public void Delete_ExistingQuery_ReturnsSuccess() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - - var queryName = "PQ_Delete_" + Guid.NewGuid().ToString("N")[..8]; - var mCode = @"let - Source = #table( - {""Column1"", ""Column2"", ""Column3""}, - { - {""Value1"", ""Value2"", ""Value3""}, - {""A"", ""B"", ""C""}, - {""X"", ""Y"", ""Z""} - } - ) -in - Source"; - - // Act - using var batch = ExcelSession.BeginBatch(testExcelFile); - _powerQueryCommands.Create(batch, queryName, mCode); - _powerQueryCommands.Delete(batch, queryName); - - // Assert - // Delete throws on error, so reaching here means success - } - - /// - /// Tests that attempting to Create a query that already exists returns an error. - /// LLM use case: "accidentally trying to create the same query twice" - /// Real bug: LLM using Create action on existing query instead of Update - /// - [Fact] - public void Create_DuplicateQueryName_ReturnsError() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - - var queryName = "TestQuery"; - var mCode = @"let - Source = #table( - {""Column1"", ""Column2"", ""Column3""}, - { - {""Value1"", ""Value2"", ""Value3""}, - {""A"", ""B"", ""C""}, - {""X"", ""Y"", ""Z""} - } - ) -in - Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Act 1: Create query first time (should succeed) - _powerQueryCommands.Create(batch, queryName, mCode); // Create throws on error - - // Act 2 & Assert: Try to Create same query again (should throw InvalidOperationException) - var exception = Assert.Throws(() => - _powerQueryCommands.Create(batch, queryName, mCode)); - - Assert.Contains("already exists", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Contains(queryName, exception.Message); - - // Verify query still exists and wasn't corrupted - var viewResult = _powerQueryCommands.View(batch, queryName); - Assert.True(viewResult.Success); - Assert.NotEmpty(viewResult.MCode); - } - - #endregion - - #region Load Destination Workflows (3 tests) - - #endregion - - #region Advanced Use Cases (1 test) - - /// - /// Tests that one Power Query can successfully reference another Power Query. - /// LLM use case: "create a query that filters data from another query" - /// - [Fact] - public void Import_QueryReferencingAnotherQuery_LoadsDataSuccessfully() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - - // Create M code for the source query (base data) - string sourceQueryMCode = @"let - Source = #table( - {""ProductID"", ""ProductName"", ""Price""}, - { - {1, ""Widget"", 10.99}, - {2, ""Gadget"", 25.50}, - {3, ""Doohickey"", 15.75} - } - ) -in - Source"; - - // Create M code for the derived query (references the source query) - string derivedQueryMCode = @"let - Source = SourceQuery, - FilteredRows = Table.SelectRows(Source, each [Price] > 15) -in - FilteredRows"; - - // Act & Assert - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Import source query first - _powerQueryCommands.Create( - batch, - "SourceQuery", - sourceQueryMCode, - loadMode: PowerQueryLoadMode.LoadToTable); // Create throws on error - - // Import derived query (references SourceQuery) - _powerQueryCommands.Create( - batch, - "DerivedQuery", - derivedQueryMCode, - loadMode: PowerQueryLoadMode.LoadToTable); // Create throws on error - - // Verify both queries exist in the workbook - var listResult = _powerQueryCommands.List(batch); - Assert.True(listResult.Success); - Assert.Equal(2, listResult.Queries.Count); - Assert.Contains(listResult.Queries, q => q.Name == "SourceQuery"); - Assert.Contains(listResult.Queries, q => q.Name == "DerivedQuery"); - - // Verify the derived query M code references SourceQuery - var derivedViewResult = _powerQueryCommands.View(batch, "DerivedQuery"); - Assert.True(derivedViewResult.Success); - Assert.Contains("SourceQuery", derivedViewResult.MCode); - Assert.Contains("Table.SelectRows", derivedViewResult.MCode); - - // Refresh both queries to ensure they execute successfully - var sourceRefreshResult = _powerQueryCommands.Refresh(batch, "SourceQuery", TimeSpan.FromMinutes(5)); - Assert.True(sourceRefreshResult.Success, - $"Source query refresh failed: {sourceRefreshResult.ErrorMessage}"); - - var derivedRefreshResult = _powerQueryCommands.Refresh(batch, "DerivedQuery", TimeSpan.FromMinutes(5)); - Assert.True(derivedRefreshResult.Success, - $"Derived query refresh failed: {derivedRefreshResult.ErrorMessage}"); - } - - #endregion - - #region Regression Tests - - /// - /// REGRESSION TEST for reported LLM bug: - /// 1. Create PowerQuery that loads to sheet - works - /// 2. Update the query and run again - /// 3. Query turns into connection-only (BUG!) - /// - /// This test verifies that Update preserves the load configuration. - /// - [Fact] - public void Update_QueryLoadedToSheet_PreservesLoadConfiguration() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - - var queryName = "LoadedQuery_" + Guid.NewGuid().ToString("N")[..8]; - var sheetName = "DataSheet"; - - // Initial M code for the query - string initialMCode = @"let - Source = #table( - {""Column1"", ""Column2"", ""Column3""}, - { - {""Value1"", ""Value2"", ""Value3""}, - {""A"", ""B"", ""C""}, - {""X"", ""Y"", ""Z""} - } - ) -in - Source"; - - // Updated M code for the query - string updatedMCode = @"let - UpdatedSource = #table( - {""NewCol1"", ""NewCol2""}, - { - {""Updated1"", ""Updated2""}, - {""Data1"", ""Data2""} - } - ) -in - UpdatedSource"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // STEP 1: Import query and load to worksheet - _powerQueryCommands.Create(batch, queryName, initialMCode, PowerQueryLoadMode.LoadToTable, sheetName); // Create throws on error - - // Verify initial load configuration - var loadConfigBefore = _powerQueryCommands.GetLoadConfig(batch, queryName); - Assert.True(loadConfigBefore.Success, "GetLoadConfig before update failed"); - Assert.Equal(PowerQueryLoadMode.LoadToTable, loadConfigBefore.LoadMode); - Assert.Equal(sheetName, loadConfigBefore.TargetSheet); - - // STEP 2: Update the query M code (now auto-refreshes) - _powerQueryCommands.Update(batch, queryName, updatedMCode); // Update throws on error - - // STEP 3: Verify load configuration is PRESERVED (regression check) - var loadConfigAfter = _powerQueryCommands.GetLoadConfig(batch, queryName); - Assert.True(loadConfigAfter.Success, "GetLoadConfig after update failed"); - - // THE BUG: This assertion should pass but might fail if Update doesn't restore load config - Assert.Equal(PowerQueryLoadMode.LoadToTable, loadConfigAfter.LoadMode); - Assert.Equal(sheetName, loadConfigAfter.TargetSheet); - - // STEP 4: Verify data is still loaded to the sheet (not connection-only) - var listResult = _sheetCommands.List(batch); - Assert.Contains(listResult.Worksheets, s => s.Name == sheetName); - } - - /// - /// REGRESSION TEST for reported user bug (2025-01-28): - /// User workflow: Create query loaded to worksheet ? UpdateMCode ? Refresh ? query becomes connection-only - /// - /// This test validates that UpdateMCode + Refresh preserves load configuration. - /// Expected: Load configuration should survive both UpdateMCode AND Refresh operations. - /// - [Fact] - public void UpdateMCodeThenRefresh_QueryLoadedToSheet_PreservesLoadConfiguration() - { - // Arrange - - var testFile = _fixture.CreateTestFile(); - - var queryName = "LoadedQuery_" + Guid.NewGuid().ToString("N")[..8]; - var sheetName = "DataSheet"; - - // Initial M code for the query - string initialMCode = @"let - Source = #table( - {""Column1"", ""Column2"", ""Column3""}, - { - {""Value1"", ""Value2"", ""Value3""}, - {""A"", ""B"", ""C""}, - {""X"", ""Y"", ""Z""} - } - ) -in - Source"; - - // Updated M code for the query - string updatedMCode = @"let - UpdatedSource = #table( - {""NewCol1"", ""NewCol2""}, - { - {""Updated1"", ""Updated2""}, - {""Data1"", ""Data2""} - } - ) -in - UpdatedSource"; - - using var batch = ExcelSession.BeginBatch(testFile); - - // STEP 1: Create query and load to worksheet - _powerQueryCommands.Create(batch, queryName, initialMCode, PowerQueryLoadMode.LoadToTable, sheetName); // Create throws on error - - // STEP 2: Verify initial load configuration - var loadConfigBefore = _powerQueryCommands.GetLoadConfig(batch, queryName); - Assert.True(loadConfigBefore.Success, "GetLoadConfig before update failed"); - Assert.Equal(PowerQueryLoadMode.LoadToTable, loadConfigBefore.LoadMode); - Assert.Equal(sheetName, loadConfigBefore.TargetSheet); - - // STEP 3: Update M code (now auto-refreshes - this is the simplified API) - _powerQueryCommands.Update(batch, queryName, updatedMCode); // Update throws on error - - // STEP 4: THE CRITICAL CHECK - Does load config survive Update (which includes refresh)? - var loadConfigAfterUpdate = _powerQueryCommands.GetLoadConfig(batch, queryName); - Assert.True(loadConfigAfterUpdate.Success, "GetLoadConfig after update failed"); - - // This assertion verifies load config is preserved through update+refresh - Assert.Equal(PowerQueryLoadMode.LoadToTable, loadConfigAfterUpdate.LoadMode); - Assert.Equal(sheetName, loadConfigAfterUpdate.TargetSheet); - - // STEP 5: Verify data is actually on the sheet (not connection-only) - - Assert.False(string.IsNullOrEmpty(loadConfigAfterUpdate.TargetSheet), - "Query should have a target sheet (not be connection-only)"); - } - - #endregion - - #region Column Structure Regression Tests (2 tests) - - /// - /// REGRESSION TEST: Validates Update properly handles column structure changes - /// - /// LLM use case: "update a query to change column structure and verify columns update" - /// - /// Scenario: - /// 1. Create a PowerQuery with one column and load to a worksheet - /// 2. Check that there is only one column - /// 3. Update the query and load again - /// 4. Check that there is still only one column - /// 5. Update the query to create two columns and load again - /// 6. Check that there are two columns (validates column structure updates correctly) - /// - /// Historical bug: QueryTable.PreserveColumnInfo=true prevented column updates - /// Fix: Set PreserveColumnInfo=false and clear worksheet before recreating QueryTable - /// - [Fact] - public void Update_QueryColumnStructure_UpdatesWorksheetColumns() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - - var queryName = "ColumnStructureQuery_" + Guid.NewGuid().ToString("N")[..8]; - var sheetName = "DataSheet"; - - // STEP 1: M code for query with ONE column - string oneColumnMCode = @"let - Source = #table( - {""Column1""}, - { - {""Value1""}, - {""Value2""} - } - ) -in - Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - _powerQueryCommands.Create(batch, queryName, oneColumnMCode, PowerQueryLoadMode.LoadToTable, sheetName); // Create throws on error - - // STEP 2: Verify there is only ONE column - var rangeCommands = new RangeCommands(); - var usedRange1 = rangeCommands.GetUsedRange(batch, sheetName); - Assert.True(usedRange1.Success, $"GetUsedRange failed: {usedRange1.ErrorMessage}"); - Assert.Equal(1, usedRange1.ColumnCount); - - // STEP 3: Updated M code (still one column, different data) - string oneColumnUpdatedMCode = @"let - Source = #table( - {""Column1""}, - { - {""UpdatedValue1""}, - {""UpdatedValue2""}, - {""UpdatedValue3""} - } - ) -in - Source"; - - // STEP 3: Update query to ONE column (now auto-refreshes) - _powerQueryCommands.Update(batch, queryName, oneColumnUpdatedMCode); // Update throws on error - - // STEP 4: Check that there is still only ONE column - var usedRange2 = rangeCommands.GetUsedRange(batch, sheetName); - Assert.True(usedRange2.Success, $"GetUsedRange after first update failed: {usedRange2.ErrorMessage}"); - Assert.Equal(1, usedRange2.ColumnCount); - - // STEP 5: M code for TWO columns - string twoColumnMCode = @"let - Source = #table( - {""Column1"", ""Column2""}, - { - {""A"", ""B""}, - {""C"", ""D""}, - {""E"", ""F""} - } - ) -in - Source"; - - // STEP 5: Update query to TWO columns (now auto-refreshes) - // This validates the fix: PreserveColumnInfo=false allows column structure updates - _powerQueryCommands.Update(batch, queryName, twoColumnMCode); // Update throws on error - - // STEP 6: Check that there are now TWO columns - // This validates the fix: PreserveColumnInfo=false allows column structure updates - var usedRange3 = rangeCommands.GetUsedRange(batch, sheetName); - Assert.True(usedRange3.Success, $"GetUsedRange after second update failed: {usedRange3.ErrorMessage}"); - - // Diagnostic: Capture actual column structure for better error messages - var values = rangeCommands.GetValues(batch, sheetName, usedRange3.RangeAddress); - Assert.True(values.Success, $"GetValues failed: {values.ErrorMessage}"); - - // Get column headers to see what columns Excel created - var headerRow = values.Values.FirstOrDefault(); - var columnNames = headerRow != null - ? string.Join(", ", headerRow.Select(c => c?.ToString() ?? "null")) - : "No headers found"; - - // Primary assertion: Verify column count is correct - Assert.True(usedRange3.ColumnCount == 2, - $"Expected 2 columns but got {usedRange3.ColumnCount}. " + - $"Actual columns: [{columnNames}]"); - - // Additional assertion: Verify values match expected structure - Assert.True(values.ColumnCount == 2, - $"Expected 2 columns in values but got {values.ColumnCount}. " + - $"Columns: [{columnNames}]"); - } - - /// - /// REGRESSION TEST: Validates SetLoadToTableAsync prevents column accumulation - /// - /// Historical bug scenario (delete/recreate workaround): - /// 1. Create query with 1 column (Column1) - /// 2. Update M code to 2 columns (Column1, Column2) - /// 3. Delete query + SetLoadToTable (recreate QueryTable) + Refresh - /// 4. BUG: Excel created 3 columns (Column1, Column1, Column2) - ACCUMULATION instead of replacing! - /// - /// Root cause: Deleting QueryTable left data on worksheet, causing visual concatenation - /// Fix: Clear worksheet data before creating new QueryTable in SetLoadToTableAsync - /// - /// This test reproduces the exact scenario from early testing where we saw accumulated columns. - /// - [Fact] - public void Update_QueryColumnStructureWithDeleteRecreate_NoAccumulation() - { - // Arrange - var testExcelFile = _fixture.CreateTestFile(); - - var queryName = "AccumulationBug_" + Guid.NewGuid().ToString("N")[..8]; - var sheetName = "TestSheet"; - - // STEP 1: M code for query with 1 column - string oneColumnMCode = @"let - Source = #table( - {""Column1""}, - { - {""A""}, - {""B""} - } - ) -in - Source"; - - using var batch = ExcelSession.BeginBatch(testExcelFile); - - // Import and load to worksheet - _powerQueryCommands.Create(batch, queryName, oneColumnMCode, PowerQueryLoadMode.LoadToTable, sheetName); // Create throws on error - - // Verify initial state: 1 column - var rangeCommands = new RangeCommands(); - var usedRange1 = rangeCommands.GetUsedRange(batch, sheetName); - Assert.True(usedRange1.Success); - Assert.Equal(1, usedRange1.ColumnCount); - - // STEP 2: M code for query with 2 columns - string twoColumnMCode = @"let - Source = #table( - {""Column1"", ""Column2""}, - { - {""X"", ""Y""}, - {""Z"", ""W""} - } - ) -in - Source"; - - // STEP 2: Update query to TWO columns (now auto-refreshes) - _powerQueryCommands.Update(batch, queryName, twoColumnMCode); // Update throws on error - - // STEP 3: Apply the DELETE + RECREATE workflow (historically caused 3-column bug) - _powerQueryCommands.Delete(batch, queryName); // Delete throws on error - - _powerQueryCommands.Create(batch, queryName, twoColumnMCode, PowerQueryLoadMode.ConnectionOnly); // Create throws on error - - _powerQueryCommands.LoadTo(batch, queryName, PowerQueryLoadMode.LoadToTable, sheetName, "A1"); // LoadTo throws on error - - var refreshResult = _powerQueryCommands.Refresh(batch, queryName, TimeSpan.FromMinutes(5)); - Assert.True(refreshResult.Success, $"Refresh failed: {refreshResult.ErrorMessage}"); - - // STEP 4: Verify NO column accumulation (fix validation) - var usedRange2 = rangeCommands.GetUsedRange(batch, sheetName); - Assert.True(usedRange2.Success); - - // Get actual column headers for diagnostic output - var values = rangeCommands.GetValues(batch, sheetName, usedRange2.RangeAddress); - Assert.True(values.Success); - - var headerRow = values.Values.FirstOrDefault(); - var columnNames = headerRow != null - ? string.Join(", ", headerRow.Select(c => c?.ToString() ?? "null")) - : "No headers found"; - - // PRIMARY ASSERTION: Validates the fix prevents column accumulation - // Should be 2 columns (Column1, Column2), NOT 3 columns (Column1, Column1, Column2) - Assert.True(usedRange2.ColumnCount == 2, - $"COLUMN ACCUMULATION DETECTED! Expected 2 columns but got {usedRange2.ColumnCount}. " + - $"Actual columns: [{columnNames}]. " + - $"The fix (clearing worksheet before creating QueryTable) should prevent accumulation."); - } - - #endregion - - #region Helper Methods - - #endregion -} - - - - - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryRefreshCpuSpinTests.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryRefreshCpuSpinTests.cs deleted file mode 100644 index 93659f8d..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/PowerQuery/PowerQueryRefreshCpuSpinTests.cs +++ /dev/null @@ -1,256 +0,0 @@ -using System.Diagnostics; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands; -using Sbroenne.ExcelMcp.Core.Models; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; -using Xunit.Abstractions; - -namespace Sbroenne.ExcelMcp.Core.Tests.Integration.Commands.PowerQuery; - -/// -/// CPU spin regression tests for Power Query refresh operations. -/// -/// These tests measure CPU usage during Power Query refresh and assert it stays -/// below a threshold. The root cause of the CPU spin is in the OleMessageFilter: -/// during blocking COM calls like connection.Refresh() or queryTable.Refresh(false), -/// inbound COM callbacks from MashupHost.exe cause either: -/// - WAITNOPROCESS rejection storm (88% CPU) — v1.8.21 behavior -/// - WAITDEFPROCESS + EnsureScanDefinedEvents spin (97% CPU) — original behavior -/// -/// The fix: Smart OleMessageFilter with EnterLongOperation/ExitLongOperation that -/// uses WAITDEFPROCESS + SERVERCALL_RETRYLATER rejection in HandleInComingCall, -/// triggering the caller's RetryRejectedCall backoff mechanism. -/// -/// These tests use List.Generate to create non-trivial Power Queries (~10K/50K rows) -/// so the refresh takes long enough to measure CPU accurately. -/// -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("Feature", "PowerQuery")] -[Trait("RequiresExcel", "true")] -[Trait("Speed", "Slow")] -[Trait("RunType", "OnDemand")] -[Collection("Sequential")] // CPU measurement requires isolation — no parallel tests -public class PowerQueryRefreshCpuSpinTests : IClassFixture -{ - private readonly TempDirectoryFixture _fixture; - private readonly PowerQueryCommands _powerQueryCommands; - private readonly ITestOutputHelper _output; - - /// - /// CPU threshold as a percentage. The bug shows ~88% CPU (WAITNOPROCESS) or ~97% - /// (WAITDEFPROCESS). With the fix, CPU should be well under 25%. - /// Using 25% as a generous threshold to avoid flaky failures from OS scheduling jitter. - /// - private const double CpuThresholdPercent = 25.0; - - /// - /// M code that generates ~10,000 rows using List.Generate. - /// Self-contained (no external data sources), takes several seconds to refresh. - /// - private const string MCode10K = """ - let - Source = List.Generate( - () => [i = 0], - each [i] < 10000, - each [i = [i] + 1], - each [ - ID = [i], - Name = "Item_" & Text.From([i]), - Value = Number.Round(Number.RandomBetween(1, 10000), 2), - Category = if Number.Mod([i], 3) = 0 then "A" else if Number.Mod([i], 3) = 1 then "B" else "C", - Date = Date.AddDays(#date(2024, 1, 1), Number.Mod([i], 365)) - ] - ), - AsTable = Table.FromRecords(Source) - in - AsTable - """; - - /// - /// M code that generates ~50,000 rows for longer refresh duration. - /// - private const string MCode50K = """ - let - Source = List.Generate( - () => [i = 0], - each [i] < 50000, - each [i = [i] + 1], - each [ - ID = [i], - Name = "Item_" & Text.From([i]), - Value = Number.Round(Number.RandomBetween(1, 10000), 2), - Category = if Number.Mod([i], 5) = 0 then "A" else if Number.Mod([i], 5) = 1 then "B" else if Number.Mod([i], 5) = 2 then "C" else if Number.Mod([i], 5) = 3 then "D" else "E", - Date = Date.AddDays(#date(2024, 1, 1), Number.Mod([i], 365)) - ] - ), - AsTable = Table.FromRecords(Source) - in - AsTable - """; - - public PowerQueryRefreshCpuSpinTests(TempDirectoryFixture fixture, ITestOutputHelper output) - { - _fixture = fixture; - _output = output; - var dataModelCommands = new DataModelCommands(); - _powerQueryCommands = new PowerQueryCommands(dataModelCommands); - } - - /// - /// CPU spin regression: Data Model query refresh must not spin the CPU. - /// - /// This exercises the connection.Refresh() path (Strategy 2 in RefreshConnectionByQueryName). - /// Data Model queries can't use QueryTable.Refresh — they go through WorkbookConnection.Refresh - /// which is the primary path affected by the OleMessageFilter CPU spin. - /// - /// Before fix: ~88% CPU (WAITNOPROCESS rejection storm) - /// After fix: <25% CPU (SERVERCALL_RETRYLATER with proper backoff) - /// - [Fact] - public void Refresh_DataModelQuery_CpuStaysBelowThreshold() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - var queryName = "CpuSpin_DM_" + Guid.NewGuid().ToString("N")[..8]; - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create query loaded to Data Model (connection.Refresh path) - _powerQueryCommands.Create(batch, queryName, MCode10K, PowerQueryLoadMode.LoadToDataModel); - - // Let initialization settle - Thread.Sleep(1000); - - // Act — measure CPU during refresh - var process = Process.GetCurrentProcess(); - var cpuBefore = process.TotalProcessorTime; - var wallBefore = Stopwatch.GetTimestamp(); - - var result = _powerQueryCommands.Refresh(batch, queryName, TimeSpan.FromMinutes(5)); - - var cpuAfter = process.TotalProcessorTime; - var wallAfter = Stopwatch.GetTimestamp(); - - // Calculate - var cpuDeltaMs = (cpuAfter - cpuBefore).TotalMilliseconds; - var wallDeltaMs = Stopwatch.GetElapsedTime(wallBefore, wallAfter).TotalMilliseconds; - var cpuPercent = (cpuDeltaMs / wallDeltaMs) * 100.0; - - _output.WriteLine($"Query: {queryName} (DataModel, 10K rows)"); - _output.WriteLine($"Wall time: {wallDeltaMs:F0}ms"); - _output.WriteLine($"CPU time: {cpuDeltaMs:F1}ms ({cpuPercent:F1}%)"); - _output.WriteLine($"Refresh success: {result.Success}"); - - // Assert — refresh must succeed - Assert.True(result.Success, $"Refresh failed: {result.ErrorMessage}"); - - // Assert — CPU must stay below threshold - Assert.True(cpuPercent < CpuThresholdPercent, - $"REGRESSION: CPU spin during Data Model refresh! {cpuPercent:F1}% " + - $"({cpuDeltaMs:F1}ms CPU / {wallDeltaMs:F0}ms wall). " + - $"Threshold: {CpuThresholdPercent}%. " + - "The OleMessageFilter is likely not rejecting inbound COM callbacks properly."); - } - - /// - /// CPU spin regression: Worksheet query refresh must not spin the CPU. - /// - /// This exercises the queryTable.Refresh(false) path (Strategy 1 in RefreshConnectionByQueryName). - /// Worksheet queries use QueryTable.Refresh which is also affected by the COM callback storm - /// from MashupHost during refresh. - /// - /// Before fix: elevated CPU from COM callback dispatching - /// After fix: <25% CPU - /// - [Fact] - public void Refresh_WorksheetQuery_CpuStaysBelowThreshold() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - var queryName = "CpuSpin_WS_" + Guid.NewGuid().ToString("N")[..8]; - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create query loaded to worksheet (queryTable.Refresh path) - _powerQueryCommands.Create(batch, queryName, MCode10K, PowerQueryLoadMode.LoadToTable); - - Thread.Sleep(1000); - - // Act - var process = Process.GetCurrentProcess(); - var cpuBefore = process.TotalProcessorTime; - var wallBefore = Stopwatch.GetTimestamp(); - - var result = _powerQueryCommands.Refresh(batch, queryName, TimeSpan.FromMinutes(5)); - - var cpuAfter = process.TotalProcessorTime; - var wallAfter = Stopwatch.GetTimestamp(); - - var cpuDeltaMs = (cpuAfter - cpuBefore).TotalMilliseconds; - var wallDeltaMs = Stopwatch.GetElapsedTime(wallBefore, wallAfter).TotalMilliseconds; - var cpuPercent = (cpuDeltaMs / wallDeltaMs) * 100.0; - - _output.WriteLine($"Query: {queryName} (Worksheet, 10K rows)"); - _output.WriteLine($"Wall time: {wallDeltaMs:F0}ms"); - _output.WriteLine($"CPU time: {cpuDeltaMs:F1}ms ({cpuPercent:F1}%)"); - _output.WriteLine($"Refresh success: {result.Success}"); - - Assert.True(result.Success, $"Refresh failed: {result.ErrorMessage}"); - - Assert.True(cpuPercent < CpuThresholdPercent, - $"REGRESSION: CPU spin during worksheet refresh! {cpuPercent:F1}% " + - $"({cpuDeltaMs:F1}ms CPU / {wallDeltaMs:F0}ms wall). " + - $"Threshold: {CpuThresholdPercent}%."); - } - - /// - /// CPU spin regression: Large Data Model query (50K rows) must not spin the CPU. - /// - /// The larger dataset produces a longer refresh duration, giving more time for the - /// COM callback storm to develop and making the CPU spin more observable. - /// This is the most sensitive test for the CPU spin bug. - /// - [Fact] - public void Refresh_DataModelQuery_LargeDataset_CpuStaysBelowThreshold() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - var queryName = "CpuSpin_DM50K_" + Guid.NewGuid().ToString("N")[..8]; - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create large query loaded to Data Model - _powerQueryCommands.Create(batch, queryName, MCode50K, PowerQueryLoadMode.LoadToDataModel); - - Thread.Sleep(1000); - - // Act - var process = Process.GetCurrentProcess(); - var cpuBefore = process.TotalProcessorTime; - var wallBefore = Stopwatch.GetTimestamp(); - - var result = _powerQueryCommands.Refresh(batch, queryName, TimeSpan.FromMinutes(10)); - - var cpuAfter = process.TotalProcessorTime; - var wallAfter = Stopwatch.GetTimestamp(); - - var cpuDeltaMs = (cpuAfter - cpuBefore).TotalMilliseconds; - var wallDeltaMs = Stopwatch.GetElapsedTime(wallBefore, wallAfter).TotalMilliseconds; - var cpuPercent = (cpuDeltaMs / wallDeltaMs) * 100.0; - - _output.WriteLine($"Query: {queryName} (DataModel, 50K rows)"); - _output.WriteLine($"Wall time: {wallDeltaMs:F0}ms"); - _output.WriteLine($"CPU time: {cpuDeltaMs:F1}ms ({cpuPercent:F1}%)"); - _output.WriteLine($"Refresh success: {result.Success}"); - - Assert.True(result.Success, $"Refresh failed: {result.ErrorMessage}"); - - Assert.True(cpuPercent < CpuThresholdPercent, - $"REGRESSION: CPU spin during large Data Model refresh! {cpuPercent:F1}% " + - $"({cpuDeltaMs:F1}ms CPU / {wallDeltaMs:F0}ms wall). " + - $"Threshold: {CpuThresholdPercent}%. " + - "50K rows should produce sustained refresh with observable spin pattern."); - } -} diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/FormatTranslationTests.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/FormatTranslationTests.cs deleted file mode 100644 index 839af63c..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/FormatTranslationTests.cs +++ /dev/null @@ -1,284 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Formatting; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands.Range; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; -using Xunit.Abstractions; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Range; - -/// -/// Tests that format translation (date and number separators) works correctly across locales. -/// -[Trait("Category", "Integration")] -[Trait("Speed", "Medium")] -[Trait("Layer", "Core")] -[Trait("Feature", "Ranges")] -[Trait("RequiresExcel", "true")] -public class FormatTranslationTests : IClassFixture -{ - private readonly ITestOutputHelper _output; - private readonly RangeTestsFixture _fixture; - private readonly RangeCommands _rangeCommands; - - public FormatTranslationTests(RangeTestsFixture fixture, ITestOutputHelper output) - { - _fixture = fixture; - _output = output; - _rangeCommands = new RangeCommands(); - } - - [Fact] - public void SetNumberFormat_USDateFormat_DisplaysCorrectly() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Log translator info - batch.Execute((ctx, ct) => - { - _output.WriteLine($"FormatTranslator: {ctx.FormatTranslator}"); - }); - - // Set a date value (45000 = March 15, 2023) - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - dynamic cell = sheet.Range["A1"]; - cell.Value2 = 45000; // March 15, 2023 - }); - - // Act - Set format using US format code "m/d/yyyy" - var result = _rangeCommands.SetNumberFormat(batch, "Sheet1", "A1", "m/d/yyyy"); - - // Assert - Assert.True(result.Success, $"SetNumberFormat failed: {result.ErrorMessage}"); - - // Verify the display is correct (not "0/d/yyyy" or other broken formats) - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - dynamic cell = sheet.Range["A1"]; - - string displayedText = cell.Text?.ToString() ?? "null"; - string appliedFormat = cell.NumberFormat?.ToString() ?? "null"; - string localFormat = cell.NumberFormatLocal?.ToString() ?? "null"; - - _output.WriteLine($"Set US format 'm/d/yyyy':"); - _output.WriteLine($" Applied NumberFormat: '{appliedFormat}'"); - _output.WriteLine($" NumberFormatLocal: '{localFormat}'"); - _output.WriteLine($" Displayed text: '{displayedText}'"); - - // The display should contain the date parts (3, 15, 2023 or 15, 3, 2023) - // NOT "0/d/yyyy" which happens when 'm' is misinterpreted as minutes (=0) - Assert.DoesNotContain("0/", displayedText); - Assert.DoesNotContain("/0/", displayedText); - - // Should contain year 2023 (45000 = March 15, 2023) - Assert.Contains("2023", displayedText); - }); - } - - [Fact] - public void SetNumberFormat_ISODateFormat_DisplaysCorrectly() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Set a date value (45000 = March 15, 2023) - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - dynamic cell = sheet.Range["A1"]; - cell.Value2 = 45000; // March 15, 2023 - }); - - // Act - Set format using ISO format "yyyy-mm-dd" - var result = _rangeCommands.SetNumberFormat(batch, "Sheet1", "A1", "yyyy-mm-dd"); - - // Assert - Assert.True(result.Success, $"SetNumberFormat failed: {result.ErrorMessage}"); - - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - dynamic cell = sheet.Range["A1"]; - - string displayedText = cell.Text?.ToString() ?? "null"; - - _output.WriteLine($"Set ISO format 'yyyy-mm-dd':"); - _output.WriteLine($" Displayed text: '{displayedText}'"); - - // Should display as ISO format: 2023-03-15 - Assert.Equal("2023-03-15", displayedText); - }); - } - - [Fact] - public void SetNumberFormat_MultipleDates_AllDisplayCorrectly() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Set date values in A1:A3 - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - sheet.Range["A1"].Value2 = 45000; // March 15, 2023 - sheet.Range["A2"].Value2 = 45001; // March 16, 2023 - sheet.Range["A3"].Value2 = 45002; // March 17, 2023 - }); - - // Act - Set all three cells with different date formats - var formats = new List> - { - new() { "m/d/yyyy" }, - new() { "mm/dd/yyyy" }, - new() { "d-mmm-yyyy" } - }; - - var result = _rangeCommands.SetNumberFormats(batch, "Sheet1", "A1:A3", formats); - - // Assert - Assert.True(result.Success, $"SetNumberFormats failed: {result.ErrorMessage}"); - - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - - var texts = new[] - { - sheet.Range["A1"].Text?.ToString() ?? "null", - sheet.Range["A2"].Text?.ToString() ?? "null", - sheet.Range["A3"].Text?.ToString() ?? "null" - }; - - _output.WriteLine($"A1 (m/d/yyyy): '{texts[0]}'"); - _output.WriteLine($"A2 (mm/dd/yyyy): '{texts[1]}'"); - _output.WriteLine($"A3 (d-mmm-yyyy): '{texts[2]}'"); - - // All should contain the year 2023, not broken format codes - foreach (var text in texts) - { - Assert.Contains("2023", text); - Assert.DoesNotContain("0/d", text); - } - }); - } - - [Fact] - public void SetNumberFormat_CurrencyFormat_NotAffected() - { - // Currency formats should NOT be affected by date translation - - var testFile = _fixture.CreateTestFile(); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Set a currency value - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - sheet.Range["A1"].Value2 = 1234.56; - }); - - // Act - Set currency format - var result = _rangeCommands.SetNumberFormat(batch, "Sheet1", "A1", "$#,##0.00"); - - // Assert - Assert.True(result.Success, $"SetNumberFormat failed: {result.ErrorMessage}"); - - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - string displayedText = sheet.Range["A1"].Text?.ToString() ?? "null"; - - _output.WriteLine($"Currency format '$#,##0.00': '{displayedText}'"); - - // Should contain the dollar sign and proper formatting - Assert.Contains("$", displayedText); - Assert.Contains("1,234.56", displayedText); - }); - } - - [Fact] - public void SetNumberFormat_TimeFormat_DisplaysCorrectly() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Set a time value (0.75 = 6:00 PM / 18:00) - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - sheet.Range["A1"].Value2 = 0.75; // 18:00 - }); - - // Act - Set time format - var result = _rangeCommands.SetNumberFormat(batch, "Sheet1", "A1", "h:mm"); - - // Assert - Assert.True(result.Success, $"SetNumberFormat failed: {result.ErrorMessage}"); - - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - string displayedText = sheet.Range["A1"].Text?.ToString() ?? "null"; - - _output.WriteLine($"Time format 'h:mm': '{displayedText}'"); - - // Should show time (18:00 or 6:00 PM depending on locale) - Assert.True(displayedText.Contains("18:00") || displayedText.Contains("6:00"), - $"Expected time display, got '{displayedText}'"); - }); - } - - [Fact] - public void SetNumberFormat_DateTimeFormat_DisplaysCorrectly() - { - // Test combined date+time format - - var testFile = _fixture.CreateTestFile(); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Set a date+time value (45000.75 = March 15, 2023 at 18:00) - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - sheet.Range["A1"].Value2 = 45000.75; - }); - - // Act - Set date+time format - var result = _rangeCommands.SetNumberFormat(batch, "Sheet1", "A1", "m/d/yyyy h:mm"); - - // Assert - Assert.True(result.Success, $"SetNumberFormat failed: {result.ErrorMessage}"); - - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - string displayedText = sheet.Range["A1"].Text?.ToString() ?? "null"; - - _output.WriteLine($"DateTime format 'm/d/yyyy h:mm': '{displayedText}'"); - - // Should contain year (date part works) - Assert.Contains("2023", displayedText); - - // Should contain time separator (time part works) - Assert.Contains(":", displayedText); - }); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.Advanced.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.Advanced.cs deleted file mode 100644 index 0ba0a133..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.Advanced.cs +++ /dev/null @@ -1,265 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands.Range; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Range; - -/// -/// Tests for advanced Range operations (clear formats, copy formulas, insert/delete, hyperlinks) -/// Optimized: Single batch per test, no SaveAsync unless testing persistence -/// -public partial class RangeCommandsTests -{ - [Fact] - [Trait("Speed", "Medium")] - public void ClearFormats_FormattedRange_RemovesFormattingOnly() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Set values with formatting - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[sheetName]; - sheet.Range["A1"].Value2 = "Test"; - sheet.Range["A1"].Font.Bold = true; - sheet.Range["A1"].Interior.Color = 255; // Red background - return 0; - }); - - // Act - Clear only formats - var result = _commands.ClearFormats(batch, sheetName, "A1"); - - // Assert - Assert.True(result.Success, $"ClearFormats failed: {result.ErrorMessage}"); - - // Verify value remains but formatting is gone - var values = _commands.GetValues(batch, sheetName, "A1"); - Assert.Equal("Test", values.Values[0][0]?.ToString()); - } - - [Fact] - [Trait("Speed", "Medium")] - public void CopyFormulas_SourceWithFormulas_CopiesFormulasOnly() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Set up source data with formulas - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[sheetName]; - sheet.Range["A1"].Value2 = 10; - sheet.Range["A2"].Value2 = 20; - sheet.Range["A3"].Formula = "=A1+A2"; - return 0; - }); - - // Act - Copy formulas to B3 - var result = _commands.CopyFormulas(batch, sheetName, "A3", sheetName, "B3"); - - // Assert - Assert.True(result.Success, $"CopyFormulas failed: {result.ErrorMessage}"); - - // Verify formula was copied (should adjust references) - var formulas = _commands.GetFormulas(batch, sheetName, "B3"); - Assert.NotNull(formulas.Formulas[0][0]); - Assert.Contains("+", formulas.Formulas[0][0]?.ToString()); - } - - [Fact] - [Trait("Speed", "Medium")] - public void InsertCells_ShiftDown_InsertsAndShiftsExisting() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Set up initial data - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[sheetName]; - sheet.Range["A1"].Value2 = "Original"; - return 0; - }); - - // Act - Insert cell at A1, shifting down - var result = _commands.InsertCells(batch, sheetName, "A1", InsertShiftDirection.Down); - - // Assert - Assert.True(result.Success, $"InsertCells failed: {result.ErrorMessage}"); - - // Verify original value shifted to A2 - var values = _commands.GetValues(batch, sheetName, "A2"); - Assert.Equal("Original", values.Values[0][0]?.ToString()); - } - - [Fact] - [Trait("Speed", "Medium")] - public void DeleteCells_ShiftUp_RemovesAndShifts() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Set up data in A1 and A2 - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[sheetName]; - sheet.Range["A1"].Value2 = "Delete Me"; - sheet.Range["A2"].Value2 = "Keep Me"; - return 0; - }); - - // Act - Delete A1, shifting up - var result = _commands.DeleteCells(batch, sheetName, "A1", DeleteShiftDirection.Up); - - // Assert - Assert.True(result.Success, $"DeleteCells failed: {result.ErrorMessage}"); - - // Verify A2 value shifted to A1 - var values = _commands.GetValues(batch, sheetName, "A1"); - Assert.Equal("Keep Me", values.Values[0][0]?.ToString()); - } - - [Fact] - [Trait("Speed", "Medium")] - public void InsertRows_BeforeExistingData_InsertsBlankRows() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Set up data in row 1 - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[sheetName]; - sheet.Range["A1"].Value2 = "Row 1"; - return 0; - }); - - // Act - Insert 2 rows at row 1 - var result = _commands.InsertRows(batch, sheetName, "1:2"); - - // Assert - Assert.True(result.Success, $"InsertRows failed: {result.ErrorMessage}"); - - // Verify original data shifted to row 3 - var values = _commands.GetValues(batch, sheetName, "A3"); - Assert.Equal("Row 1", values.Values[0][0]?.ToString()); - } - - [Fact] - [Trait("Speed", "Medium")] - public void DeleteRows_ExistingRows_RemovesRows() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Set up data in rows 1-3 - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[sheetName]; - sheet.Range["A1"].Value2 = "Row 1"; - sheet.Range["A2"].Value2 = "Row 2 - Delete"; - sheet.Range["A3"].Value2 = "Row 3"; - return 0; - }); - - // Act - Delete row 2 - var result = _commands.DeleteRows(batch, sheetName, "2:2"); - - // Assert - Assert.True(result.Success, $"DeleteRows failed: {result.ErrorMessage}"); - - // Verify row 3 shifted to row 2 - var values = _commands.GetValues(batch, sheetName, "A2"); - Assert.Equal("Row 3", values.Values[0][0]?.ToString()); - } - - [Fact] - [Trait("Speed", "Medium")] - public void InsertColumns_BeforeExistingData_InsertsBlankColumns() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Set up data in column A - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[sheetName]; - sheet.Range["A1"].Value2 = "Col A"; - return 0; - }); - - // Act - Insert 2 columns at column A (column 1) - var result = _commands.InsertColumns(batch, sheetName, "A:B"); - - // Assert - Assert.True(result.Success, $"InsertColumns failed: {result.ErrorMessage}"); - - // Verify original data shifted to column C - var values = _commands.GetValues(batch, sheetName, "C1"); - Assert.Equal("Col A", values.Values[0][0]?.ToString()); - } - - [Fact] - [Trait("Speed", "Medium")] - public void DeleteColumns_ExistingColumns_RemovesColumns() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Set up data in columns A-C - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[sheetName]; - sheet.Range["A1"].Value2 = "Col A"; - sheet.Range["B1"].Value2 = "Col B - Delete"; - sheet.Range["C1"].Value2 = "Col C"; - return 0; - }); - - // Act - Delete column B - var result = _commands.DeleteColumns(batch, sheetName, "B:B"); - - // Assert - Assert.True(result.Success, $"DeleteColumns failed: {result.ErrorMessage}"); - - // Verify column C shifted to B - var values = _commands.GetValues(batch, sheetName, "B1"); - Assert.Equal("Col C", values.Values[0][0]?.ToString()); - } - - [Fact] - [Trait("Speed", "Medium")] - public void GetHyperlink_ExistingHyperlink_ReturnsDetails() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Add a hyperlink - var addResult = _commands.AddHyperlink(batch, sheetName, "A1", "https://example.com", "Example Link"); - Assert.True(addResult.Success); - - // Act - var result = _commands.GetHyperlink(batch, sheetName, "A1"); - - // Assert - Assert.True(result.Success, $"GetHyperlink failed: {result.ErrorMessage}"); - Assert.NotEmpty(result.Hyperlinks); - var hyperlink = result.Hyperlinks[0]; - Assert.Equal("https://example.com/", hyperlink.Address); // Excel normalizes URLs by adding trailing slash - Assert.Contains("Example", hyperlink.DisplayText); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.Discovery.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.Discovery.cs deleted file mode 100644 index eb6eaa6e..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.Discovery.cs +++ /dev/null @@ -1,154 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Range; - -/// -/// Tests for range discovery operations -/// -public partial class RangeCommandsTests -{ - // === NATIVE EXCEL COM OPERATIONS TESTS === - - [Fact] - public void GetUsedRange_SheetWithSparseData_ReturnsNonEmptyCells() - { - // Arrange - use shared file, create unique sheet for this test - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - _commands.SetValues(batch, sheetName, "A1", [["Start"]]); - _commands.SetValues(batch, sheetName, "D10", [["End"]]); - - // Act - var result = _commands.GetUsedRange(batch, sheetName); - - // Assert - Assert.True(result.Success); - Assert.True(result.RowCount >= 10); - Assert.True(result.ColumnCount >= 4); - Assert.Equal("Start", result.Values[0][0]); - } - - [Fact] - public void GetCurrentRegion_CellInPopulated3x3Range_ReturnsContiguousBlock() - { - // Arrange - use shared file, create unique sheet for this test - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - _commands.SetValues(batch, sheetName, "A1:C3", - [ - [1, 2, 3], - [4, 5, 6], - [7, 8, 9] - ]); - - // Act - Get region from middle cell - var result = _commands.GetCurrentRegion(batch, sheetName, "B2"); - - // Assert - Assert.True(result.Success); - Assert.Equal(3, result.RowCount); - Assert.Equal(3, result.ColumnCount); - Assert.Equal( - 1.0, - Convert.ToDouble(result.Values[0][0], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal( - 9.0, - Convert.ToDouble(result.Values[2][2], System.Globalization.CultureInfo.InvariantCulture)); - } - - [Fact] - public void GetInfo_ValidAddress_ReturnsMetadata() - { - // Arrange - use shared file, create unique sheet for this test - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - _commands.SetValues(batch, sheetName, "A1:D10", - [ - [1, 2, 3, 4] - ]); - - // Act - var result = _commands.GetInfo(batch, sheetName, "A1:D10"); - - // Assert - Assert.True(result.Success); - Assert.Equal(10, result.RowCount); - Assert.Equal(4, result.ColumnCount); - Assert.Contains("$A$1:$D$10", result.Address); // Absolute address - } - - [Fact] - public void GetInfo_ValidRange_ReturnsGeometryInPoints() - { - // Arrange - use shared file, create unique sheet for this test - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Act - Get info for a range that has known geometry - var result = _commands.GetInfo(batch, sheetName, "A1:C5"); - - // Assert - Geometry should be populated (values vary by default column width/row height) - Assert.True(result.Success); - Assert.NotNull(result.Left); - Assert.NotNull(result.Top); - Assert.NotNull(result.Width); - Assert.NotNull(result.Height); - - // All geometry values should be positive (in points) - Assert.True(result.Left >= 0, "Left should be >= 0 points"); - Assert.True(result.Top >= 0, "Top should be >= 0 points"); - Assert.True(result.Width > 0, "Width should be > 0 points"); - Assert.True(result.Height > 0, "Height should be > 0 points"); - } - - [Fact] - public void GetInfo_DifferentRanges_ReturnsDifferentGeometry() - { - // Arrange - use shared file, create unique sheet for this test - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Act - Get info for two different ranges - var rangeA1 = _commands.GetInfo(batch, sheetName, "A1"); - var rangeB2 = _commands.GetInfo(batch, sheetName, "B2"); - - // Assert - B2 should be offset from A1 - Assert.True(rangeA1.Success); - Assert.True(rangeB2.Success); - - // B2 should have greater Left (offset by column A width) - Assert.True(rangeB2.Left > rangeA1.Left, "B2 should be to the right of A1"); - - // B2 should have greater Top (offset by row 1 height) - Assert.True(rangeB2.Top > rangeA1.Top, "B2 should be below A1"); - } - - [Fact] - public void GetInfo_LargerRange_ReturnsLargerDimensions() - { - // Arrange - use shared file, create unique sheet for this test - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Act - Compare single cell to multi-cell range - var singleCell = _commands.GetInfo(batch, sheetName, "A1"); - var multiCell = _commands.GetInfo(batch, sheetName, "A1:C5"); - - // Assert - Assert.True(singleCell.Success); - Assert.True(multiCell.Success); - - // Multi-cell range should be larger - Assert.True(multiCell.Width > singleCell.Width, "A1:C5 should be wider than A1"); - Assert.True(multiCell.Height > singleCell.Height, "A1:C5 should be taller than A1"); - } - -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.Editing.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.Editing.cs deleted file mode 100644 index c4aa47e5..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.Editing.cs +++ /dev/null @@ -1,106 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Range; - -/// -/// Tests for range editing operations -/// -public partial class RangeCommandsTests -{ - // === CLEAR OPERATIONS TESTS === - - [Fact] - public void ClearAll_FormattedRange_RemovesEverything() - { - // Arrange - use shared file, create unique sheet for this test - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - _commands.SetValues(batch, sheetName, "A1", [["Test"]]); - - // Act - var result = _commands.ClearAll(batch, sheetName, "A1"); - // Assert - Assert.True(result.Success); - - var readResult = _commands.GetValues(batch, sheetName, "A1"); - Assert.Null(readResult.Values[0][0]); - } - - [Fact] - public void ClearContents_FormattedRange_PreservesFormatting() - { - // Arrange - use shared file, create unique sheet for this test - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - _commands.SetValues(batch, sheetName, "A1:B2", - [ - [1, 2], - [3, 4] - ]); - - // Act - var result = _commands.ClearContents(batch, sheetName, "A1:B2"); - // Assert - Assert.True(result.Success); - - var readResult = _commands.GetValues(batch, sheetName, "A1:B2"); - Assert.All(readResult.Values, row => Assert.All(row, cell => Assert.Null(cell))); - } - - // === COPY OPERATIONS TESTS === - - [Fact] - public void Copy_CopiesRangeToNewLocation() - { - // Arrange - use shared file, create unique sheet for this test - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - var sourceData = new List> - { - new() { "A", "B" }, - new() { 1, 2 } - }; - - _commands.SetValues(batch, sheetName, "A1:B2", sourceData); - - // Act - var result = _commands.Copy(batch, sheetName, "A1:B2", sheetName, "D1:E2"); - // Assert - Assert.True(result.Success); - - var readResult = _commands.GetValues(batch, sheetName, "D1:E2"); - Assert.Equal("A", readResult.Values[0][0]); - Assert.Equal(2.0, Convert.ToDouble(readResult.Values[1][1], System.Globalization.CultureInfo.InvariantCulture)); - } - - [Fact] - public void CopyValues_CopiesOnlyValues() - { - // Arrange - use shared file, create unique sheet for this test - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - _commands.SetValues(batch, sheetName, "A1", [[10]]); - _commands.SetFormulas(batch, sheetName, "B1", [["=A1*2"]]); - - // Act - var result = _commands.CopyValues(batch, sheetName, "B1", sheetName, "C1"); - // Assert - Assert.True(result.Success); - - // C1 should have value 20 but no formula - var formulaResult = _commands.GetFormulas(batch, sheetName, "C1"); - Assert.Equal(20.0, Convert.ToDouble(formulaResult.Values[0][0], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Empty(formulaResult.Formulas[0][0]); // No formula - } - - // === INSERT/DELETE OPERATIONS TESTS === -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.FormulaValidation.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.FormulaValidation.cs deleted file mode 100644 index ed2eb3c8..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.FormulaValidation.cs +++ /dev/null @@ -1,253 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Range; - -/// -/// Tests for formula validation and error detection -/// Feature: #1 Formula Syntax Validation, #4 Better Error Code Mapping -/// -public partial class RangeCommandsTests -{ - // === IMPROVEMENT #1: FORMULA VALIDATION TESTS === - - [Fact] - [Trait("Feature", "Range")] - public void ValidateFormulas_WithValidFormulas_ReturnsSuccess() - { - // Arrange - use shared file, create unique sheet for this test - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Set up source data - _commands.SetValues(batch, sheetName, "A1:A3", [ - [10], - [20], - [30] - ]); - - var formulas = new List> { - new() { "=SUM(A1:A3)" }, - new() { "=AVERAGE(A1:A3)" }, - new() { "=COUNT(A1:A3)" } - }; - - // Act - validate formulas before applying them - var result = _commands.ValidateFormulas(batch, sheetName, "B1:B3", formulas); - - // Assert - Assert.True(result.Success); - Assert.True(result.IsValid); - Assert.Equal(3, result.FormulaCount); - Assert.Equal(3, result.ValidCount); - Assert.Equal(0, result.ErrorCount); - Assert.Null(result.Errors); - } - - [Fact] - [Trait("Feature", "Range")] - public void ValidateFormulas_WithUndefinedFunction_DetectsError() - { - // Arrange - use shared file - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - var formulas = new List> { - new() { "=GETVM3(4,16,\"region\")" } // Missing XA2. namespace - }; - - // Act - validate should detect missing namespace - var result = _commands.ValidateFormulas(batch, sheetName, "B1", formulas); - - // Assert - Assert.False(result.IsValid); - Assert.Equal(1, result.FormulaCount); - Assert.Equal(0, result.ValidCount); - Assert.Equal(1, result.ErrorCount); - Assert.NotNull(result.Errors); - Assert.Single(result.Errors); - - var error = result.Errors[0]; - Assert.Equal("B1", error.CellAddress); - Assert.Contains("GETVM3", error.Message); - Assert.Contains("XA2.", error.Suggestion ?? ""); - Assert.Equal("undefined-function", error.Category); - } - - [Fact] - [Trait("Feature", "Range")] - public void ValidateFormulas_WithMissingNamespace_SuggestsCorrection() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - var formulas = new List> { - new() { "=GETAKS(2,4)" }, // Missing XA2. - new() { "=XA2.GETVM3(4,16,\"region\")" } // Correct - }; - - // Act - var result = _commands.ValidateFormulas(batch, sheetName, "B1:B2", formulas); - - // Assert - Assert.False(result.IsValid); - Assert.Equal(2, result.FormulaCount); - Assert.Equal(1, result.ValidCount); - Assert.Equal(1, result.ErrorCount); - - var error = result.Errors![0]; - Assert.Equal("B1", error.CellAddress); - Assert.Contains("=XA2.GETAKS", error.Suggestion ?? ""); - } - - [Fact] - [Trait("Feature", "Range")] - public void ValidateFormulas_WithInvalidReference_DetectsError() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - var formulas = new List> { - new() { "=SUM(UnknownSheet!A1:A10)" } - }; - - // Act - var result = _commands.ValidateFormulas(batch, sheetName, "B1", formulas); - - // Assert - Assert.False(result.IsValid); - Assert.Equal(1, result.ErrorCount); - var error = result.Errors![0]; - Assert.Equal("invalid-reference", error.Category); - } - - [Fact] - [Trait("Feature", "Range")] - public void ValidateFormulas_WithSyntaxError_ReportsError() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - var formulas = new List> { - new() { "=SUM(A1:A3" } // Missing closing parenthesis - }; - - // Act - var result = _commands.ValidateFormulas(batch, sheetName, "B1", formulas); - - // Assert - Assert.False(result.IsValid); - Assert.Equal(1, result.ErrorCount); - var error = result.Errors![0]; - Assert.Equal("syntax-error", error.Category); - Assert.Contains("parenthesis", error.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - [Trait("Feature", "Range")] - public void ValidateFormulas_WithEmptyFormulas_SkipsValidation() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - var formulas = new List> { - new() { "" } // Empty (no formula) - }; - - // Act - var result = _commands.ValidateFormulas(batch, sheetName, "B1", formulas); - - // Assert - Assert.True(result.IsValid); - Assert.Equal(1, result.FormulaCount); - Assert.Equal(1, result.ValidCount); - Assert.Equal(0, result.ErrorCount); - } - - // === IMPROVEMENT #4: ERROR CODE MAPPING TESTS === - - [Fact] - [Trait("Feature", "Range")] - public void GetFormulas_WithErrorCodes_MapsToHumanReadableMessages() - { - // Arrange - use shared file - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Create a #NAME? error (undefined function) - _commands.SetFormulas(batch, sheetName, "A1", [ - ["=UNDEFINEDFUNCTION()"] - ]); - - // Act - var result = _commands.GetFormulas(batch, sheetName, "A1"); - - // Assert - should detect error and include mapping - Assert.NotNull(result.CellErrors); - Assert.NotEmpty(result.CellErrors); - - var error = result.CellErrors[0]; - Assert.Equal("A1", error.CellAddress); - Assert.Equal(-2146826259, error.ErrorCode); // #NAME? error code - Assert.Contains("undefined", error.ErrorMessage, StringComparison.OrdinalIgnoreCase); - Assert.NotNull(error.Suggestion); - } - - [Fact] - [Trait("Feature", "Range")] - public void GetFormulas_WithCircularReference_DetectsWarning() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Create circular reference: A1 = B1, B1 = A1 - _commands.SetFormulas(batch, sheetName, "A1", [ - ["=B1"] - ]); - _commands.SetFormulas(batch, sheetName, "B1", [ - ["=A1"] - ]); - - // Act - var result = _commands.GetFormulas(batch, sheetName, "A1:B1"); - - // Assert - should detect circular reference - // Note: Excel may not immediately report circular ref until calc, - // but GetFormulas enhancement should detect it - Assert.True(result.Success); - // Result may include warning about circular reference if detected - } - - [Fact] - [Trait("Feature", "Range")] - public void GetFormulas_WithComplexRange_ReturnsAllErrorsWithAddresses() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Set mix of valid and invalid formulas - _commands.SetFormulas(batch, sheetName, "A1:A3", [ - ["=1+1"], // Valid - ["=BADFUNCTION()"], // Error - ["=2+2"] // Valid - ]); - - // Act - var result = _commands.GetFormulas(batch, sheetName, "A1:A3"); - - // Assert - Assert.Equal(3, result.RowCount); - // At minimum, one error should be present for the bad function - if (result.CellErrors != null) - { - Assert.NotEmpty(result.CellErrors); - Assert.Contains(result.CellErrors, e => e.Row == 2 && e.Column == 1); - } - } -} diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.Formulas.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.Formulas.cs deleted file mode 100644 index 79982bb3..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.Formulas.cs +++ /dev/null @@ -1,657 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands.Table; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Range; - -/// -/// Tests for range formulas operations -/// -public partial class RangeCommandsTests -{ - // === FORMULA OPERATIONS TESTS === - - [Fact] - public void GetFormulas_ReturnsFormulasAndValues() - { - // Arrange - use shared file, create unique sheet for this test - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Set values and formulas - _commands.SetValues(batch, sheetName, "A1:A3", - [ - [10], - [20], - [30] - ]); - - _commands.SetFormulas(batch, sheetName, "B1", - [ - ["=SUM(A1:A3)"] - ]); - - // Act - var result = _commands.GetFormulas(batch, sheetName, "B1"); - - // Assert - Assert.True(result.Success); - Assert.Equal("=SUM(A1:A3)", result.Formulas[0][0]); - Assert.Equal( - 60.0, - Convert.ToDouble(result.Values[0][0], System.Globalization.CultureInfo.InvariantCulture)); - } - - [Fact] - public void SetFormulas_WritesFormulasToRange() - { - // Arrange - use shared file, create unique sheet for this test - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - _commands.SetValues(batch, sheetName, "A1:A3", - [ - [5], - [10], - [15] - ]); - - var formulas = new List> - { - new() { "=A1*2", "=A2*2", "=A3*2" } - }; - - // Act - var result = _commands.SetFormulas(batch, sheetName, "B1:D1", formulas); - // Assert - Assert.True(result.Success); - - // Verify values - var readResult = _commands.GetValues(batch, sheetName, "B1:D1"); - Assert.Equal( - 10.0, - Convert.ToDouble(readResult.Values[0][0], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal( - 20.0, - Convert.ToDouble(readResult.Values[0][1], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal( - 30.0, - Convert.ToDouble(readResult.Values[0][2], System.Globalization.CultureInfo.InvariantCulture)); - } - - [Fact] - public void SetFormulas_WithJsonElementFormulas_WritesFormulasCorrectly() - { - // Arrange - use shared file, create unique sheet for this test - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Set up source data - _commands.SetValues(batch, sheetName, "A1:A3", - [ - [100], - [200], - [300] - ]); - - // Simulate MCP framework JSON deserialization - // MCP receives: {"formulas": [["=SUM(A1:A3)", "=AVERAGE(A1:A3)"]]} - // Framework deserializes to List> where each string is JsonElement - string json = """[["=SUM(A1:A3)", "=AVERAGE(A1:A3)"]]"""; - var jsonDoc = System.Text.Json.JsonDocument.Parse(json); - - var testFormulas = new List>(); - foreach (var rowElement in jsonDoc.RootElement.EnumerateArray()) - { - var row = new List(); - foreach (var cellElement in rowElement.EnumerateArray()) - { - // This is JsonElement, not primitive string - row.Add(cellElement.GetString() ?? ""); - } - testFormulas.Add(row); - } - - // Act - Should handle JsonElement conversion internally - var result = _commands.SetFormulas(batch, sheetName, "B1:C1", testFormulas); - // Assert - Assert.True(result.Success, $"SetFormulas failed: {result.ErrorMessage}"); - - // Verify formulas were written correctly - var formulaResult = _commands.GetFormulas(batch, sheetName, "B1:C1"); - Assert.True(formulaResult.Success); - Assert.Equal("=SUM(A1:A3)", formulaResult.Formulas[0][0]); - Assert.Equal("=AVERAGE(A1:A3)", formulaResult.Formulas[0][1]); - - // Verify calculated values - Assert.Equal( - 600.0, - Convert.ToDouble(formulaResult.Values[0][0], System.Globalization.CultureInfo.InvariantCulture)); // SUM - Assert.Equal( - 200.0, - Convert.ToDouble(formulaResult.Values[0][1], System.Globalization.CultureInfo.InvariantCulture)); // AVERAGE - } - - [Fact] - public void ComplexFormulas_RealisticBusinessScenario_CalculatesCorrectly() - { - // Arrange - Create a realistic sales report with complex formulas - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Step 1: Set up headers - _commands.SetValues(batch, sheetName, "A1:G1", - [ - ["Product", "Q1 Sales", "Q2 Sales", "Q3 Sales", "Q4 Sales", "Total Sales", "Performance"] - ]); - - // Step 2: Set up product sales data (4 products, 4 quarters each) - _commands.SetValues(batch, sheetName, "A2:E5", - [ - ["Widget A", 15000, 18000, 22000, 25000], - ["Widget B", 12000, 14000, 16000, 18000], - ["Widget C", 8000, 9000, 11000, 13000], - ["Widget D", 20000, 22000, 24000, 26000] - ]); - - // Step 3: Add formulas for Total Sales (column F) - // Using SUM function for each row - var totalFormulas = new List> - { - new() { "=SUM(B2:E2)" }, - new() { "=SUM(B3:E3)" }, - new() { "=SUM(B4:E4)" }, - new() { "=SUM(B5:E5)" } - }; - var totalResult = _commands.SetFormulas(batch, sheetName, "F2:F5", totalFormulas); - Assert.True(totalResult.Success, $"Failed to set total formulas: {totalResult.ErrorMessage}"); - - // Step 4: Add formulas for Performance Rating (column G) - // Using IF and AVERAGE functions - var performanceFormulas = new List> - { - new() { """=IF(AVERAGE(B2:E2)>20000,"Excellent",IF(AVERAGE(B2:E2)>15000,"Good","Average"))""" }, - new() { """=IF(AVERAGE(B3:E3)>20000,"Excellent",IF(AVERAGE(B3:E3)>15000,"Good","Average"))""" }, - new() { """=IF(AVERAGE(B4:E4)>20000,"Excellent",IF(AVERAGE(B4:E4)>15000,"Good","Average"))""" }, - new() { """=IF(AVERAGE(B5:E5)>20000,"Excellent",IF(AVERAGE(B5:E5)>15000,"Good","Average"))""" } - }; - var perfResult = _commands.SetFormulas(batch, sheetName, "G2:G5", performanceFormulas); - Assert.True(perfResult.Success, $"Failed to set performance formulas: {perfResult.ErrorMessage}"); - - // Step 5: Add summary statistics row with complex formulas - _commands.SetValues(batch, sheetName, "A7", [["TOTALS"]]); - - var summaryFormulas = new List> - { - new() - { - "=SUM(B2:B5)", // Q1 Total - "=SUM(C2:C5)", // Q2 Total - "=SUM(D2:D5)", // Q3 Total - "=SUM(E2:E5)", // Q4 Total - "=SUM(F2:F5)", // Grand Total - "=CONCATENATE(\"Avg: \",TEXT(AVERAGE(F2:F5),\"$#,##0\"))" // Average with formatting - } - }; - var summaryResult = _commands.SetFormulas(batch, sheetName, "B7:G7", summaryFormulas); - Assert.True(summaryResult.Success, $"Failed to set summary formulas: {summaryResult.ErrorMessage}"); - - // Step 6: Add growth rate calculation (comparing Q4 to Q1) - _commands.SetValues(batch, sheetName, "H1", [["Growth Rate"]]); - var growthFormulas = new List> - { - new() { "=TEXT((E2-B2)/B2,\"0.0%\")" }, - new() { "=TEXT((E3-B3)/B3,\"0.0%\")" }, - new() { "=TEXT((E4-B4)/B4,\"0.0%\")" }, - new() { "=TEXT((E5-B5)/B5,\"0.0%\")" } - }; - var growthResult = _commands.SetFormulas(batch, sheetName, "H2:H5", growthFormulas); - Assert.True(growthResult.Success, $"Failed to set growth formulas: {growthResult.ErrorMessage}"); - - // Act - Retrieve and verify all calculated values - var totalsResult = _commands.GetFormulas(batch, sheetName, "F2:F5"); - var performanceResult = _commands.GetFormulas(batch, sheetName, "G2:G5"); - var summaryTotalsResult = _commands.GetFormulas(batch, sheetName, "B7:G7"); - var growthRatesResult = _commands.GetFormulas(batch, sheetName, "H2:H5"); - - // Assert - Verify formula calculations - Assert.True(totalsResult.Success); - Assert.True(performanceResult.Success); - Assert.True(summaryTotalsResult.Success); - Assert.True(growthRatesResult.Success); - - // Verify Total Sales calculations - Assert.Equal( - 80000.0, - Convert.ToDouble(totalsResult.Values[0][0], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal( - 60000.0, - Convert.ToDouble(totalsResult.Values[1][0], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal( - 41000.0, - Convert.ToDouble(totalsResult.Values[2][0], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal( - 92000.0, - Convert.ToDouble(totalsResult.Values[3][0], System.Globalization.CultureInfo.InvariantCulture)); - - // Verify Performance Ratings - Assert.Equal("Good", performanceResult.Values[0][0]); - Assert.Equal("Average", performanceResult.Values[1][0]); - Assert.Equal("Average", performanceResult.Values[2][0]); - Assert.Equal("Excellent", performanceResult.Values[3][0]); - - // Verify Summary Row Calculations - Assert.Equal( - 55000.0, - Convert.ToDouble(summaryTotalsResult.Values[0][0], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal( - 63000.0, - Convert.ToDouble(summaryTotalsResult.Values[0][1], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal( - 73000.0, - Convert.ToDouble(summaryTotalsResult.Values[0][2], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal( - 82000.0, - Convert.ToDouble(summaryTotalsResult.Values[0][3], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal( - 273000.0, - Convert.ToDouble(summaryTotalsResult.Values[0][4], System.Globalization.CultureInfo.InvariantCulture)); - var avgText = summaryTotalsResult.Values[0][5]?.ToString() ?? string.Empty; - Assert.Contains("68", avgText); - Assert.Contains("250", avgText); - - // Verify Growth Rate Calculations - Assert.Contains("%", growthRatesResult.Values[0][0]?.ToString() ?? string.Empty); - Assert.Contains("%", growthRatesResult.Values[1][0]?.ToString() ?? string.Empty); - Assert.Contains("%", growthRatesResult.Values[2][0]?.ToString() ?? string.Empty); - Assert.Contains("%", growthRatesResult.Values[3][0]?.ToString() ?? string.Empty); - - // Verify formulas are preserved correctly - Assert.Contains("SUM", totalsResult.Formulas[0][0]); - Assert.Contains("IF", performanceResult.Formulas[0][0]); - Assert.Contains("AVERAGE", performanceResult.Formulas[0][0]); - Assert.Contains("CONCATENATE", summaryTotalsResult.Formulas[0][5]); - Assert.Contains("TEXT", growthRatesResult.Formulas[0][0]); - } - - // === EDGE CASE TESTS === - - [Fact] - public void SetFormulas_CrossSheetReferences_CalculatesCorrectly() - { - // Arrange - Test that our API correctly handles cross-sheet formula references - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Create second sheet (add after the test sheet) - string dataSheetName = $"Data_{Guid.NewGuid():N}"[..31]; // Excel sheet name max 31 chars - batch.Execute((ctx, ct) => - { - dynamic sheets = ctx.Book.Worksheets; - dynamic sheet2 = sheets.Add(); - sheet2.Name = dataSheetName; - return 0; - }); - - // Set up source data on data sheet - _commands.SetValues(batch, dataSheetName, "A1:A3", - [ - [100], - [200], - [300] - ]); - - // Act - Set formulas on test sheet that reference data sheet - var formulas = new List> - { - new() { $"='{dataSheetName}'!A1", $"='{dataSheetName}'!A2", $"='{dataSheetName}'!A3" }, - new() { $"=SUM('{dataSheetName}'!A1:A3)", $"=AVERAGE('{dataSheetName}'!A1:A3)", $"=MAX('{dataSheetName}'!A1:A3)" } - }; - var result = _commands.SetFormulas(batch, sheetName, "A1:C2", formulas); - - // Assert - Assert.True(result.Success, $"SetFormulas with cross-sheet references failed: {result.ErrorMessage}"); - - // Verify formulas are preserved with sheet references - var formulaResult = _commands.GetFormulas(batch, sheetName, "A1:C2"); - Assert.True(formulaResult.Success); - - Assert.Contains(dataSheetName, formulaResult.Formulas[0][0]); - Assert.Contains(dataSheetName, formulaResult.Formulas[0][1]); - Assert.Contains(dataSheetName, formulaResult.Formulas[0][2]); - Assert.Contains(dataSheetName, formulaResult.Formulas[1][0]); - - // Verify calculated values from cross-sheet references - Assert.Equal( - 100.0, - Convert.ToDouble(formulaResult.Values[0][0], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal( - 200.0, - Convert.ToDouble(formulaResult.Values[0][1], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal( - 300.0, - Convert.ToDouble(formulaResult.Values[0][2], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal( - 600.0, - Convert.ToDouble(formulaResult.Values[1][0], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal( - 200.0, - Convert.ToDouble(formulaResult.Values[1][1], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal( - 300.0, - Convert.ToDouble(formulaResult.Values[1][2], System.Globalization.CultureInfo.InvariantCulture)); - } - - [Fact] - public void SetFormulas_AbsoluteAndRelativeReferences_PreservesReferenceTypes() - { - // Arrange - Test that our API preserves absolute ($A$1) vs relative (A1) reference types - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Set up source data - _commands.SetValues(batch, sheetName, "A1:A3", - [ - [10], - [20], - [30] - ]); - - // Act - Set formulas with different reference types - var formulas = new List> - { - new() { "=$A$1", "=A1", "=$A1", "=A$1" }, - new() { "=$A$1*2", "=A1*2", "=$A1*2", "=A$1*2" }, - new() { "=SUM($A$1:$A$3)", "=SUM(A1:A3)", "=SUM($A1:A3)", "=SUM(A$1:A$3)" } - }; - var result = _commands.SetFormulas(batch, sheetName, "B1:E3", formulas); - - // Assert - Assert.True(result.Success, $"SetFormulas with reference types failed: {result.ErrorMessage}"); - - var formulaResult = _commands.GetFormulas(batch, sheetName, "B1:E3"); - Assert.True(formulaResult.Success); - - Assert.Equal("=$A$1", formulaResult.Formulas[0][0]); - Assert.Equal("=A1", formulaResult.Formulas[0][1]); - Assert.Equal("=$A1", formulaResult.Formulas[0][2]); - Assert.Equal("=A$1", formulaResult.Formulas[0][3]); - - Assert.Contains("$A$1", formulaResult.Formulas[1][0]); - Assert.Contains("A1", formulaResult.Formulas[1][1]); - Assert.Contains("$A1", formulaResult.Formulas[1][2]); - Assert.Contains("A$1", formulaResult.Formulas[1][3]); - - Assert.Contains("$A$1:$A$3", formulaResult.Formulas[2][0]); - Assert.Contains("A1:A3", formulaResult.Formulas[2][1]); - Assert.Contains("$A1:A3", formulaResult.Formulas[2][2]); - Assert.Contains("A$1:A$3", formulaResult.Formulas[2][3]); - - Assert.Equal( - 10.0, - Convert.ToDouble(formulaResult.Values[0][0], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal( - 10.0, - Convert.ToDouble(formulaResult.Values[0][1], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal( - 10.0, - Convert.ToDouble(formulaResult.Values[0][2], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal( - 10.0, - Convert.ToDouble(formulaResult.Values[0][3], System.Globalization.CultureInfo.InvariantCulture)); - - Assert.Equal( - 20.0, - Convert.ToDouble(formulaResult.Values[1][0], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal( - 20.0, - Convert.ToDouble(formulaResult.Values[1][1], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal( - 20.0, - Convert.ToDouble(formulaResult.Values[1][2], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal( - 20.0, - Convert.ToDouble(formulaResult.Values[1][3], System.Globalization.CultureInfo.InvariantCulture)); - - Assert.Equal( - 60.0, - Convert.ToDouble(formulaResult.Values[2][0], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal( - 60.0, - Convert.ToDouble(formulaResult.Values[2][1], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal( - 60.0, - Convert.ToDouble(formulaResult.Values[2][2], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal( - 60.0, - Convert.ToDouble(formulaResult.Values[2][3], System.Globalization.CultureInfo.InvariantCulture)); - } - - [Fact] - public void SetFormulas_LargeFormulaSet_HandlesEfficientlyInBulk() - { - // Arrange - Test that our batch API handles large formula sets efficiently - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - const int rowCount = 1000; - var sourceValues = new List>(); - for (int i = 1; i <= rowCount; i++) - { - sourceValues.Add([i, i * 2, i * 3]); - } - _commands.SetValues(batch, sheetName, $"A1:C{rowCount}", sourceValues); - - var formulas = new List>(); - for (int i = 1; i <= rowCount; i++) - { - formulas.Add([$"=A{i}+B{i}+C{i}"]); - } - - var startTime = DateTime.UtcNow; - var result = _commands.SetFormulas(batch, sheetName, $"D1:D{rowCount}", formulas); - var duration = DateTime.UtcNow - startTime; - - Assert.True(result.Success, $"SetFormulas for large set failed: {result.ErrorMessage}"); - Assert.True(duration.TotalSeconds < 10, - $"Large formula set took too long: {duration.TotalSeconds:F2} seconds (expected < 10s)"); - - var sampleResult = _commands.GetFormulas(batch, sheetName, "D1"); - Assert.Equal("=A1+B1+C1", sampleResult.Formulas[0][0]); - Assert.Equal( - 6.0, - Convert.ToDouble(sampleResult.Values[0][0], System.Globalization.CultureInfo.InvariantCulture)); - - var middleResult = _commands.GetFormulas(batch, sheetName, "D500"); - Assert.Equal("=A500+B500+C500", middleResult.Formulas[0][0]); - Assert.Equal( - 3000.0, - Convert.ToDouble(middleResult.Values[0][0], System.Globalization.CultureInfo.InvariantCulture)); - - var lastResult = _commands.GetFormulas(batch, sheetName, $"D{rowCount}"); - Assert.Equal($"=A{rowCount}+B{rowCount}+C{rowCount}", lastResult.Formulas[0][0]); - Assert.Equal( - 6000.0, - Convert.ToDouble(lastResult.Values[0][0], System.Globalization.CultureInfo.InvariantCulture)); - - startTime = DateTime.UtcNow; - var bulkResult = _commands.GetFormulas(batch, sheetName, $"D1:D{rowCount}"); - duration = DateTime.UtcNow - startTime; - - Assert.True(bulkResult.Success); - Assert.Equal(rowCount, bulkResult.Formulas.Count); - Assert.True(duration.TotalSeconds < 5, - $"Bulk formula read took too long: {duration.TotalSeconds:F2} seconds (expected < 5s)"); - } - - [Fact] - public void SetFormulas_WideHorizontalRange_NoOutOfMemoryError() - { - // Regression test for bug where 0-based arrays caused "out of memory" error - // User reported: Setting formulas to A2:P2 (16 columns) failed with E_OUTOFMEMORY - // Root cause: Excel COM requires 1-based arrays, we were passing 0-based C# arrays - - // Arrange - use shared file, create unique sheet for this test - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Set up source data in row 1 - _commands.SetValues(batch, sheetName, "A1:P1", - [ - [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16] - ]); - - // Create 16 formulas referencing the cells above (simulating user's table header formulas) - var formulas = new List> - { - new() - { - "=A1*2", "=B1*2", "=C1*2", "=D1*2", - "=E1*2", "=F1*2", "=G1*2", "=H1*2", - "=I1*2", "=J1*2", "=K1*2", "=L1*2", - "=M1*2", "=N1*2", "=O1*2", "=P1*2" - } - }; - - // Act - Write 16 formulas to A2:P2 (single row, 16 columns) - var result = _commands.SetFormulas(batch, sheetName, "A2:P2", formulas); - - // Assert - Should succeed without "out of memory" error - Assert.True(result.Success, $"SetFormulas failed: {result.ErrorMessage}"); - - // Verify formulas were written correctly - var readResult = _commands.GetFormulas(batch, sheetName, "A2:P2"); - Assert.True(readResult.Success); - Assert.Single(readResult.Formulas); // One row - Assert.Equal(16, readResult.Formulas[0].Count); // 16 columns - - // Verify first, middle, and last formulas - Assert.Equal("=A1*2", readResult.Formulas[0][0]); - Assert.Equal("=H1*2", readResult.Formulas[0][7]); - Assert.Equal("=P1*2", readResult.Formulas[0][15]); - - // Verify calculated values - Assert.Equal(2.0, Convert.ToDouble(readResult.Values[0][0], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal(16.0, Convert.ToDouble(readResult.Values[0][7], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal(32.0, Convert.ToDouble(readResult.Values[0][15], System.Globalization.CultureInfo.InvariantCulture)); - } - - // === FORMULA2 REGRESSION TESTS (implicit intersection @ operator) === - - [Fact] - public void SetFormulas_InExcelTable_DoesNotInjectImplicitIntersectionOperator() - { - // Regression test: Range.Formula (legacy) injects @ implicit intersection operator - // inside Excel Tables, causing #FIELD! errors with custom functions that return - // entity cards. Range.Formula2 (modern) respects dynamic array semantics. - // See: https://github.com/sbroenne/mcp-server-excel/issues/XXX - - // Arrange - create a sheet with data and an Excel Table - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Set up data first - _commands.SetValues(batch, sheetName, "A1:B4", - [ - ["Name", "Value"], - ["Alpha", 10], - ["Beta", 20], - ["Gamma", 30] - ]); - - // Create an Excel Table over the data - var tableCommands = new TableCommands(); - var tableResult = tableCommands.Create(batch, sheetName, "Formula2TestTable", "A1:B4"); - Assert.True(tableResult.Success); - - // Act - set formulas INSIDE the table (column C, within table range) - // First expand the data range to include column C - _commands.SetValues(batch, sheetName, "C1", [["Doubled"]]); - var setResult = _commands.SetFormulas(batch, sheetName, "C2:C4", - [ - ["=B2*2"], - ["=B3*2"], - ["=B4*2"] - ]); - - // Assert - formulas should be set successfully - Assert.True(setResult.Success); - - // Read back formulas and verify NO @ operator was injected - var readResult = _commands.GetFormulas(batch, sheetName, "C2:C4"); - Assert.True(readResult.Success); - - for (int i = 0; i < readResult.Formulas.Count; i++) - { - var formula = readResult.Formulas[i][0]; - _output.WriteLine($"C{i + 2} formula: {formula}"); - - // CRITICAL: Formula must NOT start with =@ (implicit intersection) - Assert.DoesNotContain("@", formula); - - // Verify it's the expected formula - Assert.StartsWith("=B", formula); - } - - // Verify calculated values are correct - Assert.Equal(20.0, Convert.ToDouble(readResult.Values[0][0], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal(40.0, Convert.ToDouble(readResult.Values[1][0], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal(60.0, Convert.ToDouble(readResult.Values[2][0], System.Globalization.CultureInfo.InvariantCulture)); - } - - [Fact] - public void GetFormulas_InExcelTable_DoesNotReturnImplicitIntersectionOperator() - { - // Regression test: Range.Formula (legacy) returns formulas with @ prefix - // inside Excel Tables. Range.Formula2 returns them without @. - - // Arrange - create a sheet with data, table, and formulas - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - _commands.SetValues(batch, sheetName, "A1:C4", - [ - ["X", "Y", "Sum"], - [1, 2, null], - [3, 4, null], - [5, 6, null] - ]); - - // Set formulas BEFORE creating table (to ensure clean formulas without @) - _commands.SetFormulas(batch, sheetName, "C2:C4", - [ - ["=A2+B2"], - ["=A3+B3"], - ["=A4+B4"] - ]); - - // Create Excel Table around the data including formula column - var tableCommands = new TableCommands(); - var tableResult = tableCommands.Create(batch, sheetName, "GetFormula2TestTable", "A1:C4"); - Assert.True(tableResult.Success); - - // Act - read formulas back from inside the table - var readResult = _commands.GetFormulas(batch, sheetName, "C2:C4"); - - // Assert - formulas should NOT contain @ operator - Assert.True(readResult.Success); - - for (int i = 0; i < readResult.Formulas.Count; i++) - { - var formula = readResult.Formulas[i][0]; - _output.WriteLine($"C{i + 2} formula: {formula}"); - - // CRITICAL: GetFormulas must NOT return formulas with @ prefix - Assert.DoesNotContain("@", formula); - } - - // Verify calculated values - Assert.Equal(3.0, Convert.ToDouble(readResult.Values[0][0], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal(7.0, Convert.ToDouble(readResult.Values[1][0], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal(11.0, Convert.ToDouble(readResult.Values[2][0], System.Globalization.CultureInfo.InvariantCulture)); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.GetStyle.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.GetStyle.cs deleted file mode 100644 index 6bc450fe..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.GetStyle.cs +++ /dev/null @@ -1,109 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Range; - -public partial class RangeCommandsTests -{ - [Fact] - public void GetStyle_UnstyledRange_ReturnsNormalStyle() - { - // Arrange & Act - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - var result = _commands.GetStyle(batch, sheetName, "A1"); - - // Assert - Assert.True(result.Success, $"GetStyle failed: {result.ErrorMessage}"); - Assert.Equal("Normal", result.StyleName); - Assert.True(result.IsBuiltInStyle); - // Note: StyleDescription may be null for some styles - } - - [Fact] - public void GetStyle_AfterSetStyle_ReturnsAppliedStyle() - { - // Arrange & Act - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Set a style first - _commands.SetStyle(batch, sheetName, "A1", "Heading 1"); - - // Now get the style - var getResult = _commands.GetStyle(batch, sheetName, "A1"); - - // Assert - Assert.True(getResult.Success, $"GetStyle failed: {getResult.ErrorMessage}"); - Assert.Equal("Heading 1", getResult.StyleName); - Assert.True(getResult.IsBuiltInStyle); - // Note: StyleDescription may be null for some styles - } - - [Fact] - public void GetStyle_MultipleStyles_ReturnsCorrectStyles() - { - // Arrange & Act - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Set different styles on different cells - _commands.SetStyle(batch, sheetName, "A1", "Heading 1"); - _commands.SetStyle(batch, sheetName, "B1", "Accent1"); - _commands.SetStyle(batch, sheetName, "C1", "Currency"); - - // Get the styles - var getHeading1 = _commands.GetStyle(batch, sheetName, "A1"); - var getAccent1 = _commands.GetStyle(batch, sheetName, "B1"); - var getCurrency = _commands.GetStyle(batch, sheetName, "C1"); - - // Assert - Assert.True(getHeading1.Success, $"GetStyle A1 failed: {getHeading1.ErrorMessage}"); - Assert.Equal("Heading 1", getHeading1.StyleName); - Assert.True(getHeading1.IsBuiltInStyle); - - Assert.True(getAccent1.Success, $"GetStyle B1 failed: {getAccent1.ErrorMessage}"); - Assert.Equal("Accent1", getAccent1.StyleName); - Assert.True(getAccent1.IsBuiltInStyle); - - Assert.True(getCurrency.Success, $"GetStyle C1 failed: {getCurrency.ErrorMessage}"); - Assert.Equal("Currency", getCurrency.StyleName); - Assert.True(getCurrency.IsBuiltInStyle); - } - - [Fact] - public void GetStyle_RangeMultipleCells_ReturnsFirstCellStyle() - { - // Arrange & Act - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Set style on entire range (this applies to all cells in the range) - _commands.SetStyle(batch, sheetName, "A1:C3", "Good"); - - // Get style for entire range (should return first cell's style) - var getResult = _commands.GetStyle(batch, sheetName, "A1:C3"); - - // Assert - Assert.True(getResult.Success, $"GetStyle failed: {getResult.ErrorMessage}"); - Assert.Equal("Good", getResult.StyleName); - Assert.True(getResult.IsBuiltInStyle); - } - - [Fact] - public void GetStyle_InvalidRange_ThrowsException() - { - // Arrange & Act & Assert - Should throw when Excel COM rejects invalid range - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - var exception = Assert.ThrowsAny( - () => _commands.GetStyle(batch, sheetName, "InvalidRange")); - - // Verify exception is related to range access - Assert.NotNull(exception.Message); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.Hyperlinks.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.Hyperlinks.cs deleted file mode 100644 index 188f0de6..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.Hyperlinks.cs +++ /dev/null @@ -1,80 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Range; - -/// -/// Tests for range hyperlinks operations -/// -public partial class RangeCommandsTests -{ - // === HYPERLINK OPERATIONS TESTS === - - [Fact] - public void AddHyperlink_CreatesHyperlink() - { - // Arrange & Act - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - var result = _commands.AddHyperlink( - batch, - sheetName, - "A1", - "https://www.example.com", - "Example Site", - "Click to visit"); - - // Assert - Assert.True(result.Success); - - // Verify hyperlink exists - var hyperlinkResult = _commands.GetHyperlink(batch, sheetName, "A1"); - Assert.True(hyperlinkResult.Success); - Assert.Single(hyperlinkResult.Hyperlinks); - // Excel normalizes URLs - may add trailing slash - Assert.StartsWith("https://www.example.com", hyperlinkResult.Hyperlinks[0].Address); - } - - [Fact] - public void RemoveHyperlink_DeletesHyperlink() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - _commands.AddHyperlink(batch, sheetName, "A1", "https://www.example.com"); - - // Act - var result = _commands.RemoveHyperlink(batch, sheetName, "A1"); - - // Assert - Assert.True(result.Success); - - var hyperlinkResult = _commands.GetHyperlink(batch, sheetName, "A1"); - Assert.Empty(hyperlinkResult.Hyperlinks); - } - - [Fact] - public void ListHyperlinks_ReturnsAllHyperlinks() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - _commands.AddHyperlink(batch, sheetName, "A1", "https://site1.com"); - _commands.AddHyperlink(batch, sheetName, "B2", "https://site2.com"); - _commands.AddHyperlink(batch, sheetName, "C3", "https://site3.com"); - - // Act - var result = _commands.ListHyperlinks(batch, sheetName); - - // Assert - Assert.True(result.Success); - Assert.Equal(3, result.Hyperlinks.Count); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.NamedRanges.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.NamedRanges.cs deleted file mode 100644 index 5612a635..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.NamedRanges.cs +++ /dev/null @@ -1,145 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Range; - -/// -/// Tests for named range transparency - verifying that RangeCommands works seamlessly with named ranges -/// -public partial class RangeCommandsTests -{ - // === NAMED RANGE TRANSPARENCY TESTS === - - [Fact] - public void GetValues_WithNamedRange_ResolvesProperly() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Create a named range pointing to A1:B2 - var paramCommands = new NamedRangeCommands(); - paramCommands.Create(batch, "TestData", $"{sheetName}!$A$1:$B$2"); - - // Set data in the range - _commands.SetValues(batch, sheetName, "A1:B2", - [ - [1, 2], - [3, 4] - ]); - - // Act - Read using named range (empty sheetName) - var result = _commands.GetValues(batch, "", "TestData"); - - // Assert - Assert.True(result.Success); - Assert.Equal(2, result.RowCount); - Assert.Equal(2, result.ColumnCount); - Assert.Equal( - 1.0, - Convert.ToDouble(result.Values[0][0], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal( - 4.0, - Convert.ToDouble(result.Values[1][1], System.Globalization.CultureInfo.InvariantCulture)); - - // Cleanup named range - paramCommands.Delete(batch, "TestData"); - } - - [Fact] - public void SetValues_WithNamedRange_WritesProperly() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Create a named range - var paramCommands = new NamedRangeCommands(); - paramCommands.Create(batch, "SalesData", $"{sheetName}!$A$1:$C$2"); - - // Act - Write using named range - var result = _commands.SetValues(batch, "", "SalesData", - [ - ["Product", "Qty", "Price"], - ["Widget", 10, 29.99] - ]); - - // Assert - Assert.True(result.Success); - - // Verify by reading with regular range address - var readResult = _commands.GetValues(batch, sheetName, "A1:C2"); - Assert.Equal("Product", readResult.Values[0][0]); - Assert.Equal( - 29.99, - Convert.ToDouble(readResult.Values[1][2], System.Globalization.CultureInfo.InvariantCulture)); - - // Cleanup named range - paramCommands.Delete(batch, "SalesData"); - } - - [Fact] - public void GetFormulas_WithNamedRange_ReturnsFormulas() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Create named range and set data + formula - var paramCommands = new NamedRangeCommands(); - paramCommands.Create(batch, "CalcRange", $"{sheetName}!$A$1:$B$2"); - - _commands.SetValues(batch, sheetName, "A1", [[10]]); - _commands.SetFormulas(batch, sheetName, "B1", [["=A1*2"]]); - - // Act - Read formulas using named range - var result = _commands.GetFormulas(batch, "", "CalcRange"); - - // Assert - Assert.True(result.Success); - Assert.Empty(result.Formulas[0][0]); // A1 has no formula - Assert.Equal("=A1*2", result.Formulas[0][1]); - Assert.Equal( - 20.0, - Convert.ToDouble(result.Values[0][1], System.Globalization.CultureInfo.InvariantCulture)); - - // Cleanup named range - paramCommands.Delete(batch, "CalcRange"); - } - - [Fact] - public void ClearContents_WithNamedRange_ClearsData() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Create named range and populate - var paramCommands = new NamedRangeCommands(); - paramCommands.Create(batch, "TempData", $"{sheetName}!$A$1:$B$2"); - - _commands.SetValues(batch, "", "TempData", - [ - [1, 2], - [3, 4] - ]); - - // Act - Clear using named range - var result = _commands.ClearContents(batch, "", "TempData"); - - // Assert - Assert.True(result.Success); - - // Verify data is cleared - var readResult = _commands.GetValues(batch, sheetName, "A1:B2"); - Assert.All(readResult.Values, row => Assert.All(row, cell => Assert.Null(cell))); - - // Cleanup named range - paramCommands.Delete(batch, "TempData"); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.NumberFormat.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.NumberFormat.cs deleted file mode 100644 index dd230e5a..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.NumberFormat.cs +++ /dev/null @@ -1,396 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Range; - -/// -/// Integration tests for RangeCommands number formatting operations. -/// Uses raw format codes - LLMs know Excel format codes natively. -/// -public partial class RangeCommandsTests -{ - // Standard format codes - raw strings, no helper class needed - private const string FormatCurrency = "$#,##0.00"; - private const string FormatPercentage = "0.00%"; - private const string FormatPercentageOneDecimal = "0.0%"; - private const string FormatNumber = "#,##0.00"; - private const string FormatDateShort = "m/d/yyyy"; - private const string FormatText = "@"; - - // LCID-based currency format (proper Excel category recognition) - private const string FormatCurrencyLCID = "[$$-409]#,##0.00"; // US Dollar with LCID - - [Fact] - public void GetNumberFormats_SingleCell_ReturnsFormat() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Set up test data with a number format - _commands.SetValues(batch, sheetName, "A1", [[100]]); - _commands.SetNumberFormat(batch, sheetName, "A1", FormatCurrency); - - // Act - var result = _commands.GetNumberFormats(batch, sheetName, "A1"); - - // Assert - Assert.True(result.Success, $"Operation failed: {result.ErrorMessage}"); - Assert.Equal(sheetName, result.SheetName); - Assert.Equal(1, result.RowCount); - Assert.Equal(1, result.ColumnCount); - Assert.Single(result.Formats); - Assert.Single(result.Formats[0]); - // Excel might normalize format codes slightly - Assert.Contains("$", result.Formats[0][0]); // Currency format present - } - - [Fact] - public void GetNumberFormats_MultipleFormats_ReturnsArray() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Set up test data FIRST - _commands.SetValues(batch, sheetName, "A1:B2", [[100, 0.5], [200, 0.75]]); - - // THEN set different formats for each cell - var formats = new List> - { - new List { FormatCurrency, FormatPercentage }, - new List { FormatNumber, FormatPercentageOneDecimal } - }; - _commands.SetNumberFormats(batch, sheetName, "A1:B2", formats); - - // Act - var result = _commands.GetNumberFormats(batch, sheetName, "A1:B2"); - - // Assert - Assert.True(result.Success, $"Operation failed: {result.ErrorMessage}"); - Assert.Equal(2, result.RowCount); - Assert.Equal(2, result.ColumnCount); - Assert.Equal(2, result.Formats.Count); - // Verify currency and percentage symbols are present - Assert.Contains("$", result.Formats[0][0]); - Assert.Contains("%", result.Formats[0][1]); - Assert.Contains("%", result.Formats[1][1]); - } - - [Fact] - public void SetNumberFormat_Currency_AppliesFormatToRange() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Set up test data - _commands.SetValues(batch, sheetName, "A1:A3", [[100], [200], [300]]); - - // Act - var result = _commands.SetNumberFormat(batch, sheetName, "A1:A3", FormatCurrency); - - // Assert - Verify operation success - Assert.True(result.Success, $"Operation failed: {result.ErrorMessage}"); - Assert.Equal("set-number-format", result.Action); - - // Verify format was actually applied (check for currency symbol) - var verifyResult = _commands.GetNumberFormats(batch, sheetName, "A1:A3"); - Assert.True(verifyResult.Success); - Assert.Equal(3, verifyResult.Formats.Count); - Assert.All(verifyResult.Formats, row => Assert.Contains("$", row[0])); // Currency symbol present - } - - [Fact] - public void SetNumberFormat_Percentage_AppliesFormatCorrectly() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - _commands.SetValues(batch, sheetName, "B1:B2", [[0.25], [0.75]]); - - // Act - var result = _commands.SetNumberFormat(batch, sheetName, "B1:B2", FormatPercentage); - - // Assert - Assert.True(result.Success); - - // Verify format applied (check for percentage symbol) - var verifyResult = _commands.GetNumberFormats(batch, sheetName, "B1:B2"); - Assert.True(verifyResult.Success); - Assert.All(verifyResult.Formats, row => Assert.Contains("%", row[0])); // Percentage symbol present - } - - [Fact] - public void SetNumberFormat_DateFormat_AppliesCorrectly() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Excel serial date: 45000 = April 17, 2023 - _commands.SetValues(batch, sheetName, "C1", [[45000]]); - - // Act - var result = _commands.SetNumberFormat(batch, sheetName, "C1", FormatDateShort); - - // Assert - Assert.True(result.Success); - - // Verify format applied (check for date-related format characters) - var verifyResult = _commands.GetNumberFormats(batch, sheetName, "C1"); - Assert.True(verifyResult.Success); - // Date formats contain d, m, or y characters - Assert.Matches( - @"[dmy]", - verifyResult.Formats[0][0].ToLowerInvariant()); - } - - [Fact] - public void SetNumberFormats_MixedFormats_AppliesDifferentFormatsPerCell() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Set up test data - _commands.SetValues(batch, sheetName, "A1:C2", [[100, 0.5, 45000], [200, 0.75, 45100]]); - - // Act - Apply different formats to each column - var formats = new List> - { - new List { FormatCurrency, FormatPercentage, FormatDateShort }, - new List { FormatCurrency, FormatPercentage, FormatDateShort } - }; - var result = _commands.SetNumberFormats(batch, sheetName, "A1:C2", formats); - - // Assert - Assert.True(result.Success, $"Operation failed: {result.ErrorMessage}"); - - // Verify formats applied correctly (check for expected symbols/characters) - var verifyResult = _commands.GetNumberFormats(batch, sheetName, "A1:C2"); - Assert.True(verifyResult.Success); - Assert.Contains("$", verifyResult.Formats[0][0]); // Currency - Assert.Contains("%", verifyResult.Formats[0][1]); // Percentage - Assert.Matches( - @"[dmy]", - verifyResult.Formats[0][2].ToLowerInvariant()); // Date format - Assert.Contains("$", verifyResult.Formats[1][0]); // Currency - Assert.Contains("%", verifyResult.Formats[1][1]); // Percentage - Assert.Matches( - @"[dmy]", - verifyResult.Formats[1][2].ToLowerInvariant()); // Date format - } - - [Fact] - public void SetNumberFormats_DimensionMismatch_ReturnsError() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Act & Assert - Try to apply 2x2 formats to 3x3 range (should throw ArgumentException) - var formats = new List> - { - new List { FormatCurrency, FormatPercentage }, - new List { FormatNumber, FormatPercentageOneDecimal } - }; - var exception = Assert.Throws(() => - _commands.SetNumberFormats(batch, sheetName, "A1:C3", formats)); - - Assert.Contains("row count", exception.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void SetNumberFormat_TextFormat_PreservesLeadingZeros() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // First set text format, then set value (to preserve leading zeros) - _commands.SetNumberFormat(batch, sheetName, "D1", FormatText); - _commands.SetValues(batch, sheetName, "D1", [["00123"]]); - - // Act - Verify format is text - var result = _commands.GetNumberFormats(batch, sheetName, "D1"); - - // Assert - Assert.True(result.Success); - Assert.Contains("@", result.Formats[0][0]); // Text format (@) - } - - /// - /// CRITICAL TEST: Verifies Excel actually DISPLAYS formatted values correctly. - /// This catches bugs where format code is applied but Excel doesn't render it properly. - /// Uses the .Text property to read what Excel actually shows to users. - /// NOTE: Excel uses SYSTEM LOCALE for separators, not format code. LCID only controls currency symbol. - /// - [Fact] - public void SetNumberFormat_CurrencyWithLCID_DisplaysCorrectly() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Set test value - _commands.SetValues(batch, sheetName, "A1", [[1234.56]]); - - // Apply LCID-based currency format - _commands.SetNumberFormat(batch, sheetName, "A1", FormatCurrencyLCID); - - // Act - Read the displayed text and stored format directly from Excel - string displayedText = string.Empty; - string storedFormat = string.Empty; - string storedFormatLocal = string.Empty; - object rawValue = null!; - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[sheetName]; - dynamic cell = sheet.Range["A1"]; - displayedText = cell.Text?.ToString() ?? string.Empty; - storedFormat = cell.NumberFormat?.ToString() ?? string.Empty; - storedFormatLocal = cell.NumberFormatLocal?.ToString() ?? string.Empty; - rawValue = cell.Value2; - }); - - // Diagnostics - _output.WriteLine($"Format applied: {FormatCurrencyLCID}"); - _output.WriteLine($"Format stored (NumberFormat): {storedFormat}"); - _output.WriteLine($"Format stored (NumberFormatLocal): {storedFormatLocal}"); - _output.WriteLine($"Raw value: {rawValue}"); - _output.WriteLine($"Displayed text: '{displayedText}'"); - - // Assert - Verify Excel displays currency correctly - Assert.False(string.IsNullOrEmpty(displayedText), "Cell should display formatted text"); - Assert.Contains("$", displayedText); // Currency symbol from LCID - // Formatted number includes thousands separator, so check for partial match - Assert.True( - displayedText.Contains("1234") || displayedText.Contains("1,234"), - $"Number portion should be present, got: {displayedText}"); - } - - /// - /// Test using NumberFormatLocal to see if that works better for locale settings - /// - [Fact] - public void SetNumberFormatLocal_Currency_DisplaysCorrectly() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Set test value - _commands.SetValues(batch, sheetName, "A1", [[1234.56]]); - - // Apply format using NumberFormatLocal directly (locale-specific separators) - // In German locale: , is decimal separator, . is thousands separator - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[sheetName]; - dynamic cell = sheet.Range["A1"]; - // Use NumberFormatLocal with German-style separators (matching system locale) - cell.NumberFormatLocal = "$#.##0,00"; // German style: . = thousands, , = decimal - }); - - // Act - Read the displayed text - string displayedText = string.Empty; - string storedFormat = string.Empty; - string storedFormatLocal = string.Empty; - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[sheetName]; - dynamic cell = sheet.Range["A1"]; - displayedText = cell.Text?.ToString() ?? string.Empty; - storedFormat = cell.NumberFormat?.ToString() ?? string.Empty; - storedFormatLocal = cell.NumberFormatLocal?.ToString() ?? string.Empty; - }); - - // Diagnostics - _output.WriteLine($"Format applied (NumberFormatLocal): $#.##0,00"); - _output.WriteLine($"Format stored (NumberFormat): {storedFormat}"); - _output.WriteLine($"Format stored (NumberFormatLocal): {storedFormatLocal}"); - _output.WriteLine($"Displayed text: '{displayedText}'"); - - // Assert - Assert.False(string.IsNullOrEmpty(displayedText), "Cell should display formatted text"); - Assert.Contains("$", displayedText); // Currency symbol - // Should have thousands separator and 2 decimal places - } - - /// - /// Verifies that percentage format displays correctly (not just format code applied). - /// - [Fact] - public void SetNumberFormat_Percentage_DisplaysCorrectly() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Set test value (0.25 should display as 25.00%) - _commands.SetValues(batch, sheetName, "A1", [[0.25]]); - _commands.SetNumberFormat(batch, sheetName, "A1", FormatPercentage); - - // Act - Read the displayed text - string displayedText = string.Empty; - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[sheetName]; - dynamic cell = sheet.Range["A1"]; - displayedText = cell.Text?.ToString() ?? string.Empty; - }); - - // Assert - _output.WriteLine($"Value: 0.25, Format: {FormatPercentage}"); - _output.WriteLine($"Displayed text: '{displayedText}'"); - - Assert.False(string.IsNullOrEmpty(displayedText), "Cell should display formatted text"); - Assert.Contains("%", displayedText); // Percentage symbol displayed - Assert.Contains("25", displayedText); // Value multiplied by 100 - } - - /// - /// Verifies that number format displays correctly. - /// NOTE: Excel uses system locale for separators, not format code. - /// - [Fact] - public void SetNumberFormat_NumberWithThousands_DisplaysCorrectly() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Set large value to test thousands separator - _commands.SetValues(batch, sheetName, "A1", [[1234567.89]]); - _commands.SetNumberFormat(batch, sheetName, "A1", FormatNumber); - - // Act - Read the displayed text - string displayedText = string.Empty; - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[sheetName]; - dynamic cell = sheet.Range["A1"]; - displayedText = cell.Text?.ToString() ?? string.Empty; - }); - - // Assert - _output.WriteLine($"Value: 1234567.89, Format: {FormatNumber}"); - _output.WriteLine($"Displayed text: '{displayedText}'"); - - Assert.False(string.IsNullOrEmpty(displayedText), "Cell should display formatted text"); - // Formatted number includes thousands separator (comma or period depending on locale) - Assert.True( - displayedText.Contains("1234567") || displayedText.Contains("1,234,567") || displayedText.Contains("1.234.567"), - $"Number portion should be present, got: {displayedText}"); - // Decimal separator depends on locale (. or ,) - Assert.True( - displayedText.Contains("89") || displayedText.Contains(",89") || displayedText.Contains(".89"), - $"Decimal portion should be displayed, got: {displayedText}"); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.Search.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.Search.cs deleted file mode 100644 index 7554f6d7..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.Search.cs +++ /dev/null @@ -1,99 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands.Range; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Range; - -/// -/// Tests for range search operations -/// -public partial class RangeCommandsTests -{ - // === FIND/REPLACE OPERATIONS TESTS === - - [Fact] - public void Find_FindsMatchingCells() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - _commands.SetValues(batch, sheetName, "A1:C2", - [ - ["Apple", "Banana", "Apple"], - ["Cherry", "Apple", "Banana"] - ]); - - // Act - var result = _commands.Find(batch, sheetName, "A1:C2", "Apple", new FindOptions - { - MatchCase = false, - MatchEntireCell = true - }); - - // Assert - Assert.True(result.Success); - Assert.Equal(3, result.MatchingCells.Count); // Should find 3 "Apple" cells - } - - [Fact] - public void Replace_ReplacesAllOccurrences() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - _commands.SetValues(batch, sheetName, "A1:A3", - [ - ["cat"], - ["dog"], - ["cat"] - ]); - - // Act - _commands.Replace(batch, sheetName, "A1:A3", "cat", "bird", new ReplaceOptions - { - ReplaceAll = true - }); - - // Assert - void method throws on failure, succeeds silently - var readResult = _commands.GetValues(batch, sheetName, "A1:A3"); - Assert.Equal("bird", readResult.Values[0][0]); - Assert.Equal("dog", readResult.Values[1][0]); - Assert.Equal("bird", readResult.Values[2][0]); - } - - // === SORT OPERATIONS TESTS === - - [Fact] - public void Sort_SortsRangeByColumn() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - _commands.SetValues(batch, sheetName, "A1:B4", - [ - ["Name", "Age"], - ["Charlie", 30], - ["Alice", 25], - ["Bob", 35] - ]); - - // Act - Sort by first column (Name) ascending - _commands.Sort(batch, sheetName, "A1:B4", - [ - new() { ColumnIndex = 1, Ascending = true } - ], hasHeaders: true); - - // Assert - void method throws on failure, succeeds silently - var readResult = _commands.GetValues(batch, sheetName, "A2:A4"); - Assert.Equal("Alice", readResult.Values[0][0]); - Assert.Equal("Bob", readResult.Values[1][0]); - Assert.Equal("Charlie", readResult.Values[2][0]); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.SetStyle.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.SetStyle.cs deleted file mode 100644 index b2e1fcfe..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.SetStyle.cs +++ /dev/null @@ -1,121 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Range; - -public partial class RangeCommandsTests -{ - [Fact] - public void SetStyle_Heading1_AppliesSuccessfully() - { - // Arrange & Act - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - _commands.SetStyle(batch, sheetName, "A1", "Heading 1"); - - // Assert - void method throws on failure, succeeds silently on success - } - - [Fact] - public void SetStyle_GoodBadNeutral_AllApplySuccessfully() - { - // Arrange & Act & Assert - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - _commands.SetStyle(batch, sheetName, "A1", "Good"); - _commands.SetStyle(batch, sheetName, "A2", "Bad"); - _commands.SetStyle(batch, sheetName, "A3", "Neutral"); - // void methods throw on failure, succeed silently - } - - [Fact] - public void SetStyle_Accent1_AppliesSuccessfully() - { - // Arrange & Act - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - _commands.SetStyle(batch, sheetName, "A1:E1", "Accent1"); - - // Assert - void method throws on failure, succeeds silently on success - } - - [Fact] - public void SetStyle_TotalStyle_AppliesSuccessfully() - { - // Arrange & Act - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - _commands.SetStyle(batch, sheetName, "A10:E10", "Total"); - - // Assert - void method throws on failure, succeeds silently on success - } - - [Fact] - public void SetStyle_CurrencyComma_AppliesSuccessfully() - { - // Arrange & Act & Assert - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - _commands.SetStyle(batch, sheetName, "B5:B10", "Currency"); - _commands.SetStyle(batch, sheetName, "C5:C10", "Comma"); - // void methods throw on failure, succeed silently - } - - [Fact] - public void SetStyle_InvalidStyleName_ThrowsException() - { - // Arrange & Act & Assert - Should throw when Excel COM rejects invalid style name - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - var exception = Assert.Throws( - () => _commands.SetStyle(batch, sheetName, "A1", "NonExistentStyle")); - - // Verify exception message contains context about the style operation - Assert.NotNull(exception.Message); - Assert.Contains("Style", exception.Message); - } - - [Fact] - public void SetStyle_ResetToNormal_ClearsFormatting() - { - // Arrange & Act - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Apply fancy style - _commands.SetStyle(batch, sheetName, "A1", "Accent1"); - - // Reset to normal - _commands.SetStyle(batch, sheetName, "A1", "Normal"); - // void methods throw on failure, succeed silently - } - - /// - /// Regression test: FormatRange with verticalAlignment='middle' must succeed - /// (treated as alias for 'center'). - /// - [Fact] - public void FormatRange_VerticalAlignmentMiddle_AcceptedAsAlias() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Act - 'middle' is a common alias for 'center' - var result = _commands.FormatRange( - batch, sheetName, "A1:C3", - fontName: null, fontSize: null, bold: null, italic: null, underline: null, - fontColor: null, fillColor: null, borderStyle: null, borderColor: null, borderWeight: null, - horizontalAlignment: null, verticalAlignment: "middle", - wrapText: null, orientation: null); - - // Assert - Assert.True(result.Success, $"FormatRange with verticalAlignment=middle failed: {result.ErrorMessage}"); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.Validation.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.Validation.cs deleted file mode 100644 index 64bbe068..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.Validation.cs +++ /dev/null @@ -1,131 +0,0 @@ -// Copyright (c) Stefan Broenne. All rights reserved. - -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Range; - -public partial class RangeCommandsTests -{ - [Fact] - public void ValidateRange_WithInputMessage_ReturnsSuccess() - { - // Arrange & Act - First write list values to worksheet (required for dropdown) - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - _commands.SetValues( - batch, - sheetName, - "B1:B3", - new List> - { - new() { "Option1" }, - new() { "Option2" }, - new() { "Option3" } - }); - - // Apply validation referencing the range (creates dropdown) - _commands.ValidateRange( - batch, - sheetName, - "A1", - validationType: "list", - validationOperator: null, - formula1: "=$B$1:$B$3", // Reference to worksheet range creates dropdown - formula2: null, - showInputMessage: true, - inputTitle: "My Input Title", - inputMessage: "My helpful input message", - showErrorAlert: true, - errorStyle: "stop", - errorTitle: "My Error Title", - errorMessage: "My error message", - ignoreBlank: true, - showDropdown: true); - // void method throws on failure, succeeds silently - - // Verify validation is retrieved correctly (same batch) - var getResult = _commands.GetValidation(batch, sheetName, "A1"); - - // Assert - Validation retrieved successfully - Assert.True(getResult.Success, $"Get validation failed: {getResult.ErrorMessage}"); - Assert.True(getResult.HasValidation, "Range should have validation"); - - // Assert - Validation type and formula are correct - Assert.Equal("list", getResult.ValidationType); - Assert.Equal("=$B$1:$B$3", getResult.Formula1); - - // Assert - Input message properties are returned - Assert.Equal("My Input Title", getResult.InputTitle); - Assert.Equal("My helpful input message", getResult.InputMessage); - - // Assert - Error message properties are returned (these work according to the bug report) - Assert.Equal("My Error Title", getResult.ErrorTitle); - Assert.Equal("My error message", getResult.ValidationErrorMessage); - } - - [Fact] - public void GetValidation_WithInputMessage_ReturnsInputTitleAndMessage() - { - // Arrange & Act - First write list values to worksheet (required for dropdown) - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - _commands.SetValues( - batch, - sheetName, - "B1:B3", - new List> - { - new() { "Option1" }, - new() { "Option2" }, - new() { "Option3" } - }); - - // Apply validation using the ValidateRangeAsync API - _commands.ValidateRange( - batch, - sheetName, - "A1", - validationType: "list", - validationOperator: null, // Not used for list type - formula1: "=$B$1:$B$3", // Reference to worksheet range creates dropdown - formula2: null, - showInputMessage: true, - inputTitle: "My Input Title", - inputMessage: "My helpful input message", - showErrorAlert: true, - errorStyle: "stop", - errorTitle: "My Error Title", - errorMessage: "My error message", - ignoreBlank: true, - showDropdown: true); - // void method throws on failure, succeeds silently - - // Act - Get validation to verify InputTitle/InputMessage are returned - var result = _commands.GetValidation(batch, sheetName, "A1"); - - // Assert - Assert.True(result.Success, $"Get validation failed: {result.ErrorMessage}"); - Assert.True(result.HasValidation, "Range should have validation"); - - // Assert - Validation type and formula create dropdown with 3 values - Assert.Equal("list", result.ValidationType); - Assert.Equal("=$B$1:$B$3", result.Formula1); - - // CRITICAL: These assertions test the bug fix - Assert.NotEmpty(result.InputTitle ?? string.Empty); - Assert.Equal("My Input Title", result.InputTitle); - Assert.NotEmpty(result.InputMessage ?? string.Empty); - Assert.Equal("My helpful input message", result.InputMessage); - - // These should work (error properties worked before the fix) - Assert.Equal("My Error Title", result.ErrorTitle); - Assert.Equal("My error message", result.ValidationErrorMessage); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.Values.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.Values.cs deleted file mode 100644 index bec8950e..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.Values.cs +++ /dev/null @@ -1,211 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Range; - -/// -/// Tests for range values operations -/// -public partial class RangeCommandsTests -{ - // === VALUE OPERATIONS TESTS === - - [Fact] - public void GetValues_SingleCell_Returns1x1Array() - { - // Arrange - use shared file, create unique sheet for this test - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Set a value first - _commands.SetValues(batch, sheetName, "A1", [[100]]); - - // Act - var result = _commands.GetValues(batch, sheetName, "A1"); - - // Assert - Assert.True(result.Success, $"Failed: {result.ErrorMessage}"); - Assert.Equal(1, result.RowCount); - Assert.Equal(1, result.ColumnCount); - Assert.Single(result.Values); - Assert.Single(result.Values[0]); - Assert.Equal( - 100.0, - Convert.ToDouble(result.Values[0][0], System.Globalization.CultureInfo.InvariantCulture)); - } - - [Fact] - public void GetValues_3x3Range_Returns2DArray() - { - // Arrange - use shared file, create unique sheet for this test - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - var testData = new List> - { - new() { 1, 2, 3 }, - new() { 4, 5, 6 }, - new() { 7, 8, 9 } - }; - - _commands.SetValues(batch, sheetName, "A1:C3", testData); - - // Act - var result = _commands.GetValues(batch, sheetName, "A1:C3"); - - // Assert - Assert.True(result.Success); - Assert.Equal(3, result.RowCount); - Assert.Equal(3, result.ColumnCount); - Assert.Equal(3, result.Values.Count); - Assert.Equal( - 1.0, - Convert.ToDouble(result.Values[0][0], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal( - 9.0, - Convert.ToDouble(result.Values[2][2], System.Globalization.CultureInfo.InvariantCulture)); - } - - [Fact] - public void SetValues_TableWithHeaders_WritesAndReadsBack() - { - // Arrange - use shared file, create unique sheet for this test - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - var testData = new List> - { - new() { "Name", "Age" }, - new() { "Alice", 30 }, - new() { "Bob", 25 } - }; - - // Act - var result = _commands.SetValues(batch, sheetName, "A1:B3", testData); - // Assert - Assert.True(result.Success); - - // Verify by reading back - var readResult = _commands.GetValues(batch, sheetName, "A1:B3"); - Assert.Equal("Name", readResult.Values[0][0]); - Assert.Equal( - 30.0, - Convert.ToDouble(readResult.Values[1][1], System.Globalization.CultureInfo.InvariantCulture)); - } - - [Fact] - public void SetValues_JsonElementStrings_WritesCorrectly() - { - // Arrange - Simulate MCP Server scenario where JSON deserialization creates JsonElement objects - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Simulate MCP JSON: [["Azure Region Code", "Azure Region Name", "Geography", "Country"]] - string json = """[["Azure Region Code", "Azure Region Name", "Geography", "Country"]]"""; - var jsonDoc = System.Text.Json.JsonDocument.Parse(json); - var jsonArray = jsonDoc.RootElement; - - // Convert to List> containing JsonElement objects (like MCP does) - var testData = new List>(); - foreach (var rowElement in jsonArray.EnumerateArray()) - { - var row = new List(); - foreach (var cellElement in rowElement.EnumerateArray()) - { - row.Add(cellElement); // This is a JsonElement, not a string! - } - testData.Add(row); - } - - // Act - var result = _commands.SetValues(batch, sheetName, "A1:D1", testData); - // Assert - Assert.True(result.Success, $"SetValuesAsync failed: {result.ErrorMessage}"); - - // Verify by reading back - var readResult = _commands.GetValues(batch, sheetName, "A1:D1"); - Assert.Equal("Azure Region Code", readResult.Values[0][0]); - Assert.Equal("Azure Region Name", readResult.Values[0][1]); - Assert.Equal("Geography", readResult.Values[0][2]); - Assert.Equal("Country", readResult.Values[0][3]); - } - - [Fact] - public void SetValues_JsonElementMixedTypes_WritesCorrectly() - { - // Arrange - Test different JSON value types (string, number, boolean, null) - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Simulate MCP JSON: [["Text", 123, true, null]] - string json = """[["Text", 123, true, null]]"""; - var jsonDoc = System.Text.Json.JsonDocument.Parse(json); - var jsonArray = jsonDoc.RootElement; - - // Convert to List> containing JsonElement objects - var testData = new List>(); - foreach (var rowElement in jsonArray.EnumerateArray()) - { - var row = new List(); - foreach (var cellElement in rowElement.EnumerateArray()) - { - row.Add(cellElement); // JsonElement - } - testData.Add(row); - } - - // Act - var result = _commands.SetValues(batch, sheetName, "A1:D1", testData); - // Assert - Assert.True(result.Success, $"SetValuesAsync failed: {result.ErrorMessage}"); - - // Verify by reading back - var readResult = _commands.GetValues(batch, sheetName, "A1:D1"); - Assert.Equal("Text", readResult.Values[0][0]); - Assert.Equal( - 123.0, - Convert.ToDouble(readResult.Values[0][1], System.Globalization.CultureInfo.InvariantCulture)); // Excel stores as double - Assert.Equal(true, readResult.Values[0][2]); - // Excel COM returns null (not empty string) for empty cells - Assert.True(readResult.Values[0][3] == null || readResult.Values[0][3]?.ToString() == string.Empty); - } - - [Fact] - public void SetValues_WideHorizontalRange_NoOutOfMemoryError() - { - // Regression test for bug where 0-based arrays caused "out of memory" error - // Root cause: Excel COM requires 1-based arrays, we were passing 0-based C# arrays - - // Arrange - use shared file, create unique sheet for this test - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = _fixture.CreateTestSheet(batch); - - // Create test data with 16 columns (matching user's A2:P2 scenario) - var testData = new List> - { - new object?[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 }.ToList() - }; - - // Act - Write 16 values to A1:P1 (single row, 16 columns) - var result = _commands.SetValues(batch, sheetName, "A1:P1", testData); - - // Assert - Should succeed without "out of memory" error - Assert.True(result.Success, $"SetValues failed: {result.ErrorMessage}"); - - // Verify values were written correctly - var readResult = _commands.GetValues(batch, sheetName, "A1:P1"); - Assert.True(readResult.Success); - Assert.Single(readResult.Values); // One row - Assert.Equal(16, readResult.Values[0].Count); // 16 columns - - // Verify first, middle, and last values - Assert.Equal(1.0, Convert.ToDouble(readResult.Values[0][0], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal(8.0, Convert.ToDouble(readResult.Values[0][7], System.Globalization.CultureInfo.InvariantCulture)); - Assert.Equal(16.0, Convert.ToDouble(readResult.Values[0][15], System.Globalization.CultureInfo.InvariantCulture)); - } - -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.cs deleted file mode 100644 index e72fb453..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Range/RangeCommandsTests.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Sbroenne.ExcelMcp.Core.Commands.Range; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; -using Xunit.Abstractions; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Range; - -/// -/// Integration tests for RangeCommands - main partial class with shared fixture. -/// Uses RangeTestsFixture to create ONE file shared across all tests in this class. -/// Each test creates its own sheet within that file for isolation. -/// Other test methods are in partial files: Values.cs, Formulas.cs, Editing.cs, Search.cs, Discovery.cs, Hyperlinks.cs -/// -[Trait("Category", "Integration")] -[Trait("Speed", "Medium")] -[Trait("Feature", "Range")] -[Trait("RequiresExcel", "true")] -public partial class RangeCommandsTests : IClassFixture -{ - private readonly ITestOutputHelper _output; - private readonly RangeCommands _commands; - private readonly RangeTestsFixture _fixture; - - /// - /// Initializes a new instance of the test class with shared fixture - /// - public RangeCommandsTests(ITestOutputHelper output, RangeTestsFixture fixture) - { - _output = output; - _commands = new RangeCommands(); - _fixture = fixture; - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Screenshot/ScreenshotCommandsTests.Capture.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Screenshot/ScreenshotCommandsTests.Capture.cs deleted file mode 100644 index c7e82412..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Screenshot/ScreenshotCommandsTests.Capture.cs +++ /dev/null @@ -1,235 +0,0 @@ -// -// Copyright (c) Stephan Brenner. All rights reserved. -// - -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands.Screenshot; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Integration.Commands.Screenshot; - -/// -/// Tests for CaptureRange and CaptureSheet operations. -/// These exercise the CopyPicture + ChartObject.Export pipeline including retry logic. -/// -public partial class ScreenshotCommandsTests -{ - /// - /// Helper: populates a test file with sample data and optionally a chart. - /// - private static void PopulateTestData(IExcelBatch batch, bool addChart = false) - { - batch.Execute((ctx, ct) => - { - dynamic? sheet = null; - dynamic? chartObjects = null; - dynamic? chartObject = null; - dynamic? chart = null; - try - { - sheet = ctx.Book.Worksheets[1]; - - sheet.Range["A1"].Value2 = "Region"; - sheet.Range["B1"].Value2 = "Sales"; - sheet.Range["A2"].Value2 = "North"; - sheet.Range["B2"].Value2 = 45000; - sheet.Range["A3"].Value2 = "South"; - sheet.Range["B3"].Value2 = 38000; - sheet.Range["A4"].Value2 = "East"; - sheet.Range["B4"].Value2 = 51000; - sheet.Range["A5"].Value2 = "West"; - sheet.Range["B5"].Value2 = 42000; - - if (addChart) - { - chartObjects = sheet.ChartObjects(); - chartObject = chartObjects.Add(150, 100, 400, 250); - chart = chartObject.Chart; - chart.SetSourceData(sheet.Range["A1:B5"]); - chart.ChartType = 51; // xlColumnClustered - } - } - finally - { - ComUtilities.Release(ref chart); - ComUtilities.Release(ref chartObject); - ComUtilities.Release(ref chartObjects); - ComUtilities.Release(ref sheet); - } - }); - } - - [Fact] - public void CaptureRange_SmallRange_ReturnsValidPng() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(show: true, operationTimeout: null, testFile); - PopulateTestData(batch); - - // Act — High quality produces PNG - var result = _commands.CaptureRange(batch, rangeAddress: "A1:B5", quality: ScreenshotQuality.High); - - // Assert - Assert.True(result.Success, $"CaptureRange failed: {result.ErrorMessage}"); - Assert.NotNull(result.ImageBase64); - Assert.NotEmpty(result.ImageBase64); - Assert.Equal("image/png", result.MimeType); - Assert.True(result.Width > 0, "Width should be positive"); - Assert.True(result.Height > 0, "Height should be positive"); - - // Verify it's valid base64 that decodes to a PNG - byte[] imageBytes = Convert.FromBase64String(result.ImageBase64); - Assert.True(imageBytes.Length > 100, "Image should be more than 100 bytes"); - // PNG magic bytes: 137 80 78 71 - Assert.Equal(137, imageBytes[0]); - Assert.Equal(80, imageBytes[1]); - Assert.Equal(78, imageBytes[2]); - Assert.Equal(71, imageBytes[3]); - } - - [Fact] - public void CaptureRange_MediumQuality_ReturnsJpeg() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(show: true, operationTimeout: null, testFile); - PopulateTestData(batch); - - // Act — Medium quality (default) produces JPEG - var result = _commands.CaptureRange(batch, rangeAddress: "A1:B5"); - - // Assert - Assert.True(result.Success, $"CaptureRange failed: {result.ErrorMessage}"); - Assert.NotNull(result.ImageBase64); - Assert.NotEmpty(result.ImageBase64); - Assert.Equal("image/jpeg", result.MimeType); - Assert.True(result.Width > 0, "Width should be positive"); - Assert.True(result.Height > 0, "Height should be positive"); - - // Verify it's valid JPEG (SOI marker: FF D8) - byte[] imageBytes = Convert.FromBase64String(result.ImageBase64); - Assert.True(imageBytes.Length > 100, "Image should be more than 100 bytes"); - Assert.Equal(0xFF, imageBytes[0]); - Assert.Equal(0xD8, imageBytes[1]); - } - - [Fact] - public void CaptureRange_AreaWithChart_ReturnsLargerImage() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(show: true, operationTimeout: null, testFile); - PopulateTestData(batch, addChart: true); - - // Act — capture a wider area that includes the chart region (High = PNG for size comparison) - var result = _commands.CaptureRange(batch, rangeAddress: "A1:M20", quality: ScreenshotQuality.High); - - // Assert - Assert.True(result.Success, $"CaptureRange failed: {result.ErrorMessage}"); - Assert.NotNull(result.ImageBase64); - Assert.Equal("image/png", result.MimeType); - Assert.True(result.Width > 0); - Assert.True(result.Height > 0); - - byte[] imageBytes = Convert.FromBase64String(result.ImageBase64); - Assert.True(imageBytes.Length > 500, "Image with chart area should be larger"); - } - - [Fact] - public void CaptureSheet_NamedSheet_ReturnsValidPng() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(show: true, operationTimeout: null, testFile); - PopulateTestData(batch); - - // Get the actual sheet name - string sheetName = batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - return sheet.Name?.ToString() ?? "Sheet1"; - }); - - // Act — capture entire used area by sheet name (High = PNG for magic-byte assertion) - var result = _commands.CaptureSheet(batch, sheetName, quality: ScreenshotQuality.High); - - // Assert - Assert.True(result.Success, $"CaptureSheet failed: {result.ErrorMessage}"); - Assert.NotNull(result.ImageBase64); - Assert.NotEmpty(result.ImageBase64); - Assert.Equal("image/png", result.MimeType); - Assert.True(result.Width > 0); - Assert.True(result.Height > 0); - Assert.Equal(sheetName, result.SheetName); - } - - [Fact] - public void CaptureSheet_ActiveSheet_ReturnsValidPng() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(show: true, operationTimeout: null, testFile); - PopulateTestData(batch); - - // Act — capture active sheet (no sheetName specified), High quality for PNG assertion - var result = _commands.CaptureSheet(batch, quality: ScreenshotQuality.High); - - // Assert - Assert.True(result.Success, $"CaptureSheet failed: {result.ErrorMessage}"); - Assert.NotNull(result.ImageBase64); - Assert.Equal("image/png", result.MimeType); - } - - [Fact] - public void CaptureRange_DefaultRange_ReturnsValidPng() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(show: true, operationTimeout: null, testFile); - PopulateTestData(batch); - - // Act — use default range (A1:Z30) with no sheetName, High quality for PNG assertion - var result = _commands.CaptureRange(batch, quality: ScreenshotQuality.High); - - // Assert - Assert.True(result.Success, $"CaptureRange failed: {result.ErrorMessage}"); - Assert.NotNull(result.ImageBase64); - Assert.Equal("image/png", result.MimeType); - Assert.True(result.Width > 0); - Assert.True(result.Height > 0); - } - - [Fact] - public void CaptureRange_MessageIncludesDimensions() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(show: true, operationTimeout: null, testFile); - PopulateTestData(batch); - - // Act - var result = _commands.CaptureRange(batch, rangeAddress: "A1:B5"); - - // Assert — message should contain pixel dimensions - Assert.True(result.Success); - Assert.Contains("px", result.Message); - } - - [Fact] - public void CaptureRange_ConsecutiveCalls_AllSucceed() - { - // This test validates the retry logic handles rapid successive CopyPicture calls - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(show: true, operationTimeout: null, testFile); - PopulateTestData(batch, addChart: true); - - for (int i = 0; i < 3; i++) - { - var result = _commands.CaptureRange(batch, rangeAddress: "A1:B5"); - Assert.True(result.Success, $"CaptureRange call {i + 1} failed: {result.ErrorMessage}"); - Assert.NotNull(result.ImageBase64); - } - } -} diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Screenshot/ScreenshotCommandsTests.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Screenshot/ScreenshotCommandsTests.cs deleted file mode 100644 index 90494177..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Screenshot/ScreenshotCommandsTests.cs +++ /dev/null @@ -1,30 +0,0 @@ -// -// Copyright (c) Stephan Brenner. All rights reserved. -// - -using Sbroenne.ExcelMcp.Core.Commands.Screenshot; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Integration.Commands.Screenshot; - -/// -/// Integration tests for Screenshot commands. -/// Tests CaptureRange and CaptureSheet with real Excel data, charts, and tables. -/// Validates the CopyPicture retry logic that handles intermittent COM failures. -/// -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("RequiresExcel", "true")] -[Trait("Feature", "Screenshot")] -public partial class ScreenshotCommandsTests : IClassFixture -{ - private readonly ScreenshotCommands _commands; - private readonly ScreenshotTestsFixture _fixture; - - public ScreenshotCommandsTests(ScreenshotTestsFixture fixture) - { - _commands = new ScreenshotCommands(); - _fixture = fixture; - } -} diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Sheet/SheetCommandsTests.Lifecycle.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Sheet/SheetCommandsTests.Lifecycle.cs deleted file mode 100644 index e697b0c4..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Sheet/SheetCommandsTests.Lifecycle.cs +++ /dev/null @@ -1,129 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Sheet; - -/// -/// Tests for Sheet lifecycle operations (list, create, delete, rename, copy) -/// -public partial class SheetCommandsTests -{ - /// - [Fact] - public void List_DefaultWorkbook_ReturnsDefaultSheets() - { - // Arrange & Act - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var result = _sheetCommands.List(batch); - - // Assert - Assert.True(result.Success, $"Expected success but got error: {result.ErrorMessage}"); - Assert.NotNull(result.Worksheets); - Assert.NotEmpty(result.Worksheets); // Shared file has Sheet1 plus test sheets - } - - /// - /// Regression test: Visible property must be correctly read from Excel. - /// Previously defaulted to false without reading sheet.Visible. - /// - [Fact] - public void List_VisibleSheets_ReturnsVisibleTrue() - { - // Arrange & Act - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var result = _sheetCommands.List(batch); - - // Assert - all default sheets should be visible - Assert.True(result.Success); - Assert.NotEmpty(result.Worksheets); - Assert.All(result.Worksheets, sheet => - Assert.True(sheet.Visible, $"Sheet '{sheet.Name}' should be visible but Visible={sheet.Visible}")); - } - /// - - [Fact] - public void Create_UniqueName_ReturnsSuccess() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = $"Create_{Guid.NewGuid():N}"[..31]; // Unique name, max 31 chars - - // Act - _sheetCommands.Create(batch, sheetName); - // Create throws on error, so reaching here means success - - // Verify sheet actually exists - var listResult = _sheetCommands.List(batch); - Assert.True(listResult.Success); - Assert.Contains(listResult.Worksheets, w => w.Name == sheetName); - } - /// - - [Fact] - public void Rename_ExistingSheet_ReturnsSuccess() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var uniqueId = Guid.NewGuid().ToString("N")[..8]; - var oldName = $"Old_{uniqueId}"; - var newName = $"New_{uniqueId}"; - _sheetCommands.Create(batch, oldName); - - // Act - _sheetCommands.Rename(batch, oldName, newName); - // Rename throws on error, so reaching here means success - - // Verify rename actually happened - var listResult = _sheetCommands.List(batch); - Assert.True(listResult.Success); - Assert.DoesNotContain(listResult.Worksheets, w => w.Name == oldName); - Assert.Contains(listResult.Worksheets, w => w.Name == newName); - } - /// - - [Fact] - public void Delete_NonActiveSheet_ReturnsSuccess() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = $"Del_{Guid.NewGuid():N}"[..31]; - _sheetCommands.Create(batch, sheetName); - - // Act - _sheetCommands.Delete(batch, sheetName); - // Delete throws on error, so reaching here means success - - // Verify sheet is actually gone - var listResult = _sheetCommands.List(batch); - Assert.True(listResult.Success); - Assert.DoesNotContain(listResult.Worksheets, w => w.Name == sheetName); - } - /// - - [Fact] - public void Copy_ExistingSheet_CreatesNewSheet() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var uniqueId = Guid.NewGuid().ToString("N")[..8]; - var sourceName = $"Src_{uniqueId}"; - var targetName = $"Tgt_{uniqueId}"; - _sheetCommands.Create(batch, sourceName); - - // Act - _sheetCommands.Copy(batch, sourceName, targetName); // Copy throws on error - - // Assert - reaching here means copy succeeded - - // Verify both source and target sheets exist - var listResult = _sheetCommands.List(batch); - Assert.True(listResult.Success); - Assert.Contains(listResult.Worksheets, w => w.Name == sourceName); - Assert.Contains(listResult.Worksheets, w => w.Name == targetName); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Sheet/SheetCommandsTests.Move.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Sheet/SheetCommandsTests.Move.cs deleted file mode 100644 index a7daf463..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Sheet/SheetCommandsTests.Move.cs +++ /dev/null @@ -1,441 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Sheet; - -/// -/// Tests for Sheet move and cross-file operations (move, copy-to-file, move-to-file) -/// -public partial class SheetCommandsTests -{ - // ======================================== - // MOVE (within workbook) Tests - // ======================================== - - /// - [Fact] - public void Move_WithBeforeSheet_RepositionsSheet() - { - // Arrange - Move tests need isolated file to control sheet order - var testFile = _fixture.CreateCrossWorkbookTestFile(nameof(Move_WithBeforeSheet_RepositionsSheet)); - - using var batch = ExcelSession.BeginBatch(testFile); - _sheetCommands.Create(batch, "MoveMe"); - _sheetCommands.Create(batch, "Target"); - - // Get initial position of MoveMe - _sheetCommands.List(batch); - - // Act - Move MoveMe before Sheet1 - _sheetCommands.Move(batch, "MoveMe", beforeSheet: "Sheet1"); // Move throws on error - - // Assert - reaching here means move succeeded - - // Verify MoveMe moved to a different position (should now be before Sheet1) - var afterList = _sheetCommands.List(batch); - var sheets = afterList.Worksheets.ToList(); - var afterIndex = sheets.FindIndex(s => s.Name == "MoveMe"); - var sheet1Index = sheets.FindIndex(s => s.Name == "Sheet1"); - - // MoveMe should be before Sheet1 - Assert.True(afterIndex < sheet1Index, $"Expected MoveMe (index {afterIndex}) to be before Sheet1 (index {sheet1Index})"); - } - - /// - [Fact] - public void Move_WithAfterSheet_RepositionsSheet() - { - // Arrange - Move tests need isolated file to control sheet order - var testFile = _fixture.CreateCrossWorkbookTestFile(nameof(Move_WithAfterSheet_RepositionsSheet)); - - using var batch = ExcelSession.BeginBatch(testFile); - _sheetCommands.Create(batch, "MoveMe"); - _sheetCommands.Create(batch, "Target"); - - // Get initial position - _sheetCommands.List(batch); - - // Act - Move MoveMe after Target - _sheetCommands.Move(batch, "MoveMe", afterSheet: "Target"); // Move throws on error - - // Assert - reaching here means move succeeded - - // Verify MoveMe is now after Target - var afterList = _sheetCommands.List(batch); - var sheets = afterList.Worksheets.ToList(); - var afterIndex = sheets.FindIndex(s => s.Name == "MoveMe"); - var targetIndex = sheets.FindIndex(s => s.Name == "Target"); - - // MoveMe should be after Target - Assert.True(afterIndex > targetIndex, $"Expected MoveMe (index {afterIndex}) to be after Target (index {targetIndex})"); - } - - /// - [Fact] - public void Move_NoPositionSpecified_MovesToEnd() - { - // Arrange - Move tests need isolated file to control sheet order - var testFile = _fixture.CreateCrossWorkbookTestFile(nameof(Move_NoPositionSpecified_MovesToEnd)); - - using var batch = ExcelSession.BeginBatch(testFile); - _sheetCommands.Create(batch, "Sheet2"); - _sheetCommands.Create(batch, "Sheet3"); - - // Act - Move Sheet1 without specifying position - _sheetCommands.Move(batch, "Sheet1"); // Move throws on error - - // Assert - reaching here means move succeeded - - // Verify Sheet1 is now at the end - var listResult = _sheetCommands.List(batch); - Assert.True(listResult.Success); - var sheets = listResult.Worksheets.ToList(); - Assert.Equal("Sheet1", sheets[^1].Name); // Last sheet - } - - /// - [Fact] - public void Move_BothBeforeAndAfter_ThrowsException() - { - // Arrange - var testFile = _fixture.CreateCrossWorkbookTestFile(nameof(Move_BothBeforeAndAfter_ThrowsException)); - - using var batch = ExcelSession.BeginBatch(testFile); - _sheetCommands.Create(batch, "Sheet2"); - _sheetCommands.Create(batch, "Sheet3"); - - // Act & Assert - Should throw when both beforeSheet and afterSheet are specified - var exception = Assert.Throws( - () => _sheetCommands.Move(batch, "Sheet1", beforeSheet: "Sheet2", afterSheet: "Sheet3")); - Assert.Contains("both beforeSheet and afterSheet", exception.Message, StringComparison.OrdinalIgnoreCase); - } - - /// - [Fact] - public void Move_NonExistentSheet_ThrowsException() - { - // Arrange - var testFile = _fixture.CreateCrossWorkbookTestFile(nameof(Move_NonExistentSheet_ThrowsException)); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Act & Assert - Should throw when sheet doesn't exist - var exception = Assert.Throws( - () => _sheetCommands.Move(batch, "NonExistent", afterSheet: "Sheet1")); - Assert.Contains("not found", exception.Message, StringComparison.OrdinalIgnoreCase); - } - - /// - [Fact] - public void Move_NonExistentTargetSheet_ThrowsException() - { - // Arrange - var testFile = _fixture.CreateCrossWorkbookTestFile(nameof(Move_NonExistentTargetSheet_ThrowsException)); - - using var batch = ExcelSession.BeginBatch(testFile); - _sheetCommands.Create(batch, "Sheet2"); - - // Act & Assert - Should throw when target sheet doesn't exist - var exception = Assert.Throws( - () => _sheetCommands.Move(batch, "Sheet2", beforeSheet: "NonExistent")); - Assert.Contains("not found", exception.Message, StringComparison.OrdinalIgnoreCase); - } - - // ======================================== - // COPY-TO-FILE (atomic cross-file) Tests - // Tests for copying sheets between different files using atomic operations - // ======================================== - - [Fact] - public void CopyToFile_WithTargetName_CopiesAndRenames() - { - // Arrange - Create source and target files with a sheet to copy - var sourceFile = _fixture.CreateCrossWorkbookTestFile(nameof(CopyToFile_WithTargetName_CopiesAndRenames), "Source"); - var targetFile = _fixture.CreateCrossWorkbookTestFile(nameof(CopyToFile_WithTargetName_CopiesAndRenames), "Target"); - - // Create source sheet using a batch - using (var batch = ExcelSession.BeginBatch(sourceFile)) - { - _sheetCommands.Create(batch, "SourceSheet"); - batch.Save(); - } - - // Act - Copy sheet to target file with new name (atomic operation) - _sheetCommands.CopyToFile(sourceFile, "SourceSheet", targetFile, "CopiedSheet"); - - // Assert - Verify sheet exists in target file with new name - using (var batch = ExcelSession.BeginBatch(targetFile)) - { - var targetList = _sheetCommands.List(batch); - Assert.Contains(targetList.Worksheets, s => s.Name == "CopiedSheet"); - } - - // Verify source sheet still exists in source file - using (var batch = ExcelSession.BeginBatch(sourceFile)) - { - var sourceList = _sheetCommands.List(batch); - Assert.Contains(sourceList.Worksheets, s => s.Name == "SourceSheet"); - } - } - - [Fact] - public void CopyToFile_NoTargetName_CopiesWithOriginalName() - { - // Arrange - var sourceFile = _fixture.CreateCrossWorkbookTestFile(nameof(CopyToFile_NoTargetName_CopiesWithOriginalName), "Source"); - var targetFile = _fixture.CreateCrossWorkbookTestFile(nameof(CopyToFile_NoTargetName_CopiesWithOriginalName), "Target"); - - using (var batch = ExcelSession.BeginBatch(sourceFile)) - { - _sheetCommands.Create(batch, "SourceSheet"); - batch.Save(); - } - - // Act - Copy without specifying target name - _sheetCommands.CopyToFile(sourceFile, "SourceSheet", targetFile); - - // Assert - Verify sheet was copied with original name - using (var batch = ExcelSession.BeginBatch(targetFile)) - { - var targetList = _sheetCommands.List(batch); - Assert.Contains(targetList.Worksheets, s => s.Name == "SourceSheet"); - } - } - - [Fact] - public void CopyToFile_WithBeforeSheet_PositionsCorrectly() - { - // Arrange - var sourceFile = _fixture.CreateCrossWorkbookTestFile(nameof(CopyToFile_WithBeforeSheet_PositionsCorrectly), "Source"); - var targetFile = _fixture.CreateCrossWorkbookTestFile(nameof(CopyToFile_WithBeforeSheet_PositionsCorrectly), "Target"); - - using (var batch = ExcelSession.BeginBatch(sourceFile)) - { - _sheetCommands.Create(batch, "SourceSheet"); - batch.Save(); - } - - // Act - Copy before Sheet1 in target file - _sheetCommands.CopyToFile(sourceFile, "SourceSheet", targetFile, "Copied", beforeSheet: "Sheet1"); - - // Assert - Verify sheet was copied and positioned before Sheet1 - using (var batch = ExcelSession.BeginBatch(targetFile)) - { - var targetList = _sheetCommands.List(batch); - var sheets = targetList.Worksheets.ToList(); - var copiedIndex = sheets.FindIndex(s => s.Name == "Copied"); - var sheet1Index = sheets.FindIndex(s => s.Name == "Sheet1"); - Assert.True(copiedIndex < sheet1Index, $"Expected Copied (index {copiedIndex}) to be before Sheet1 (index {sheet1Index})"); - } - } - - [Fact] - public void CopyToFile_WithAfterSheet_PositionsCorrectly() - { - // Arrange - var sourceFile = _fixture.CreateCrossWorkbookTestFile(nameof(CopyToFile_WithAfterSheet_PositionsCorrectly), "Source"); - var targetFile = _fixture.CreateCrossWorkbookTestFile(nameof(CopyToFile_WithAfterSheet_PositionsCorrectly), "Target"); - - using (var batch = ExcelSession.BeginBatch(sourceFile)) - { - _sheetCommands.Create(batch, "SourceSheet"); - batch.Save(); - } - - // Act - Copy after Sheet1 in target file - _sheetCommands.CopyToFile(sourceFile, "SourceSheet", targetFile, "Copied", afterSheet: "Sheet1"); - - // Assert - Verify sheet was copied and positioned after Sheet1 - using (var batch = ExcelSession.BeginBatch(targetFile)) - { - var targetList = _sheetCommands.List(batch); - var sheets = targetList.Worksheets.ToList(); - var copiedIndex = sheets.FindIndex(s => s.Name == "Copied"); - var sheet1Index = sheets.FindIndex(s => s.Name == "Sheet1"); - Assert.True(copiedIndex > sheet1Index, $"Expected Copied (index {copiedIndex}) to be after Sheet1 (index {sheet1Index})"); - } - } - - [Fact] - public void CopyToFile_BothBeforeAndAfter_ThrowsException() - { - // Arrange - var sourceFile = _fixture.CreateCrossWorkbookTestFile(nameof(CopyToFile_BothBeforeAndAfter_ThrowsException), "Source"); - var targetFile = _fixture.CreateCrossWorkbookTestFile(nameof(CopyToFile_BothBeforeAndAfter_ThrowsException), "Target"); - - using (var batch = ExcelSession.BeginBatch(sourceFile)) - { - _sheetCommands.Create(batch, "SourceSheet"); - batch.Save(); - } - - // Act & Assert - Should throw ArgumentException when both beforeSheet and afterSheet specified - var exception = Assert.Throws( - () => _sheetCommands.CopyToFile(sourceFile, "SourceSheet", targetFile, "Copied", beforeSheet: "Sheet1", afterSheet: "Sheet1")); - Assert.Contains("both beforeSheet and afterSheet", exception.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void CopyToFile_SameFile_ThrowsException() - { - // Arrange - var testFile = _fixture.CreateCrossWorkbookTestFile(nameof(CopyToFile_SameFile_ThrowsException), "Test"); - - // Act & Assert - Should throw when source and target are the same - var exception = Assert.Throws( - () => _sheetCommands.CopyToFile(testFile, "Sheet1", testFile, "Copied")); - Assert.Contains("same-file copy", exception.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void CopyToFile_SourceFileNotFound_ThrowsException() - { - // Arrange - var targetFile = _fixture.CreateCrossWorkbookTestFile(nameof(CopyToFile_SourceFileNotFound_ThrowsException), "Target"); - var nonExistentSource = Path.Combine(_fixture.TempDir, "NonExistent.xlsx"); - - // Act & Assert - var exception = Assert.Throws( - () => _sheetCommands.CopyToFile(nonExistentSource, "Sheet1", targetFile)); - Assert.Contains("Source file not found", exception.Message); - } - - [Fact] - public void CopyToFile_TargetFileNotFound_ThrowsException() - { - // Arrange - var sourceFile = _fixture.CreateCrossWorkbookTestFile(nameof(CopyToFile_TargetFileNotFound_ThrowsException), "Source"); - var nonExistentTarget = Path.Combine(_fixture.TempDir, "NonExistent.xlsx"); - - // Act & Assert - var exception = Assert.Throws( - () => _sheetCommands.CopyToFile(sourceFile, "Sheet1", nonExistentTarget)); - Assert.Contains("Target file not found", exception.Message); - } - - // ======================================== - // MOVE-TO-FILE (atomic cross-file) Tests - // Tests for moving sheets between different files using atomic operations - // ======================================== - - [Fact] - public void MoveToFile_Default_MovesSheetSuccessfully() - { - // Arrange - var sourceFile = _fixture.CreateCrossWorkbookTestFile(nameof(MoveToFile_Default_MovesSheetSuccessfully), "Source"); - var targetFile = _fixture.CreateCrossWorkbookTestFile(nameof(MoveToFile_Default_MovesSheetSuccessfully), "Target"); - - using (var batch = ExcelSession.BeginBatch(sourceFile)) - { - _sheetCommands.Create(batch, "MoveMe"); - batch.Save(); - } - - // Act - Move sheet to target file (atomic operation) - _sheetCommands.MoveToFile(sourceFile, "MoveMe", targetFile); - - // Assert - Verify sheet exists in target file - using (var batch = ExcelSession.BeginBatch(targetFile)) - { - var targetList = _sheetCommands.List(batch); - Assert.Contains(targetList.Worksheets, s => s.Name == "MoveMe"); - } - - // Verify sheet no longer exists in source file - using (var batch = ExcelSession.BeginBatch(sourceFile)) - { - var sourceList = _sheetCommands.List(batch); - Assert.DoesNotContain(sourceList.Worksheets, s => s.Name == "MoveMe"); - } - } - - [Fact] - public void MoveToFile_WithBeforeSheet_PositionsCorrectly() - { - // Arrange - var sourceFile = _fixture.CreateCrossWorkbookTestFile(nameof(MoveToFile_WithBeforeSheet_PositionsCorrectly), "Source"); - var targetFile = _fixture.CreateCrossWorkbookTestFile(nameof(MoveToFile_WithBeforeSheet_PositionsCorrectly), "Target"); - - using (var batch = ExcelSession.BeginBatch(sourceFile)) - { - _sheetCommands.Create(batch, "MoveMe"); - batch.Save(); - } - - // Act - Move before Sheet1 in target file - _sheetCommands.MoveToFile(sourceFile, "MoveMe", targetFile, beforeSheet: "Sheet1"); - - // Assert - Verify sheet was moved and positioned correctly - using (var batch = ExcelSession.BeginBatch(targetFile)) - { - var targetList = _sheetCommands.List(batch); - var sheets = targetList.Worksheets.ToList(); - var moveMeIndex = sheets.FindIndex(s => s.Name == "MoveMe"); - var sheet1Index = sheets.FindIndex(s => s.Name == "Sheet1"); - Assert.True(moveMeIndex < sheet1Index, $"Expected MoveMe (index {moveMeIndex}) to be before Sheet1 (index {sheet1Index})"); - } - } - - [Fact] - public void MoveToFile_WithAfterSheet_PositionsCorrectly() - { - // Arrange - var sourceFile = _fixture.CreateCrossWorkbookTestFile(nameof(MoveToFile_WithAfterSheet_PositionsCorrectly), "Source"); - var targetFile = _fixture.CreateCrossWorkbookTestFile(nameof(MoveToFile_WithAfterSheet_PositionsCorrectly), "Target"); - - using (var batch = ExcelSession.BeginBatch(sourceFile)) - { - _sheetCommands.Create(batch, "MoveMe"); - batch.Save(); - } - - // Act - Move after Sheet1 in target file - _sheetCommands.MoveToFile(sourceFile, "MoveMe", targetFile, afterSheet: "Sheet1"); - - // Assert - Verify sheet was moved and positioned correctly - using (var batch = ExcelSession.BeginBatch(targetFile)) - { - var targetList = _sheetCommands.List(batch); - var sheets = targetList.Worksheets.ToList(); - var moveMeIndex = sheets.FindIndex(s => s.Name == "MoveMe"); - var sheet1Index = sheets.FindIndex(s => s.Name == "Sheet1"); - Assert.True(moveMeIndex > sheet1Index, $"Expected MoveMe (index {moveMeIndex}) to be after Sheet1 (index {sheet1Index})"); - } - } - - [Fact] - public void MoveToFile_BothBeforeAndAfter_ThrowsException() - { - // Arrange - var sourceFile = _fixture.CreateCrossWorkbookTestFile(nameof(MoveToFile_BothBeforeAndAfter_ThrowsException), "Source"); - var targetFile = _fixture.CreateCrossWorkbookTestFile(nameof(MoveToFile_BothBeforeAndAfter_ThrowsException), "Target"); - - using (var batch = ExcelSession.BeginBatch(sourceFile)) - { - _sheetCommands.Create(batch, "MoveMe"); - batch.Save(); - } - - // Act & Assert - var exception = Assert.Throws( - () => _sheetCommands.MoveToFile(sourceFile, "MoveMe", targetFile, beforeSheet: "Sheet1", afterSheet: "Sheet1")); - Assert.Contains("both beforeSheet and afterSheet", exception.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void MoveToFile_SameFile_ThrowsException() - { - // Arrange - var testFile = _fixture.CreateCrossWorkbookTestFile(nameof(MoveToFile_SameFile_ThrowsException), "Test"); - - // Act & Assert - Should throw when source and target are the same - var exception = Assert.Throws( - () => _sheetCommands.MoveToFile(testFile, "Sheet1", testFile)); - Assert.Contains("same-file move", exception.Message, StringComparison.OrdinalIgnoreCase); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Sheet/SheetCommandsTests.TabColor.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Sheet/SheetCommandsTests.TabColor.cs deleted file mode 100644 index 3025b3b3..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Sheet/SheetCommandsTests.TabColor.cs +++ /dev/null @@ -1,181 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Sheet; - -/// -/// Integration tests for worksheet tab color operations -/// -public partial class SheetCommandsTests -{ - /// - - [Fact] - public void SetTabColor_WithValidRGB_SetsColorCorrectly() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = $"Color_{Guid.NewGuid():N}"[..31]; - _sheetCommands.Create(batch, sheetName); - - // Act - Set red color - _sheetCommands.SetTabColor(batch, sheetName, 255, 0, 0); // SetTabColor throws on error - - // Assert - reaching here means set succeeded - - // Verify color was actually set by reading it back - var getResult = _sheetCommands.GetTabColor(batch, sheetName); - Assert.True(getResult.Success); - Assert.True(getResult.HasColor); - Assert.Equal(255, getResult.Red); - Assert.Equal(0, getResult.Green); - Assert.Equal(0, getResult.Blue); - Assert.Equal("#FF0000", getResult.HexColor); - } - /// - - [Fact] - public void SetTabColor_WithDifferentColors_AllSetCorrectly() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var uniqueId = Guid.NewGuid().ToString("N")[..6]; - var redSheet = $"Red_{uniqueId}"; - var greenSheet = $"Green_{uniqueId}"; - var blueSheet = $"Blue_{uniqueId}"; - - // Create multiple sheets - _sheetCommands.Create(batch, redSheet); - _sheetCommands.Create(batch, greenSheet); - _sheetCommands.Create(batch, blueSheet); - - // Act - Set different colors - _sheetCommands.SetTabColor(batch, redSheet, 255, 0, 0); - _sheetCommands.SetTabColor(batch, greenSheet, 0, 255, 0); - _sheetCommands.SetTabColor(batch, blueSheet, 0, 0, 255); - - // Assert - Verify each color - var redColor = _sheetCommands.GetTabColor(batch, redSheet); - Assert.True(redColor.HasColor); - Assert.Equal(255, redColor.Red); - Assert.Equal(0, redColor.Green); - Assert.Equal(0, redColor.Blue); - - var greenColor = _sheetCommands.GetTabColor(batch, greenSheet); - Assert.True(greenColor.HasColor); - Assert.Equal(0, greenColor.Red); - Assert.Equal(255, greenColor.Green); - Assert.Equal(0, greenColor.Blue); - - var blueColor = _sheetCommands.GetTabColor(batch, blueSheet); - Assert.True(blueColor.HasColor); - Assert.Equal(0, blueColor.Red); - Assert.Equal(0, blueColor.Green); - Assert.Equal(255, blueColor.Blue); - } - /// - - [Fact] - public void GetTabColor_WithNoColor_ReturnsHasColorFalse() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = $"NoClr_{Guid.NewGuid():N}"[..31]; - _sheetCommands.Create(batch, sheetName); - - // Act - var result = _sheetCommands.GetTabColor(batch, sheetName); - - // Assert - Assert.True(result.Success); - Assert.False(result.HasColor); - Assert.Null(result.Red); - Assert.Null(result.Green); - Assert.Null(result.Blue); - Assert.Null(result.HexColor); - } - /// - - [Fact] - public void ClearTabColor_RemovesColor() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = $"Clear_{Guid.NewGuid():N}"[..31]; - _sheetCommands.Create(batch, sheetName); - _sheetCommands.SetTabColor(batch, sheetName, 255, 165, 0); // Orange - - // Verify color is set - var beforeClear = _sheetCommands.GetTabColor(batch, sheetName); - Assert.True(beforeClear.HasColor); - - // Act - Clear color - _sheetCommands.ClearTabColor(batch, sheetName); // ClearTabColor throws on error - - // Assert - reaching here means clear succeeded - - var afterClear = _sheetCommands.GetTabColor(batch, sheetName); - Assert.True(afterClear.Success); - Assert.False(afterClear.HasColor); - } - /// - - [Fact] - public void SetTabColor_WithInvalidRGB_ThrowsException() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = $"Inv_{Guid.NewGuid():N}"[..31]; - _sheetCommands.Create(batch, sheetName); - - // Act & Assert - Should throw ArgumentException for invalid RGB values - var exception1 = Assert.Throws( - () => _sheetCommands.SetTabColor(batch, sheetName, 256, 0, 0)); // Red too high - Assert.Contains("must be between 0 and 255", exception1.Message); - - var exception2 = Assert.Throws( - () => _sheetCommands.SetTabColor(batch, sheetName, 0, -1, 0)); // Green negative - Assert.Contains("must be between 0 and 255", exception2.Message); - } - /// - - [Fact] - public void SetTabColor_WithNonExistentSheet_ThrowsException() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - - // Act & Assert - Non-existent sheet should throw InvalidOperationException - var exception = Assert.Throws(() => - _sheetCommands.SetTabColor(batch, $"NonExistent_{Guid.NewGuid():N}", 255, 0, 0)); - - Assert.Contains("not found", exception.Message); - } - /// - - [Fact] - public void TabColor_RGBToBGRConversion_WorksCorrectly() - { - // Arrange - Test BGR conversion accuracy - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = $"Conv_{Guid.NewGuid():N}"[..31]; - _sheetCommands.Create(batch, sheetName); - - // Act - Set a complex color (purple: RGB(128, 0, 128)) - _sheetCommands.SetTabColor(batch, sheetName, 128, 0, 128); - - // Assert - Verify conversion accuracy - var result = _sheetCommands.GetTabColor(batch, sheetName); - Assert.True(result.Success); - Assert.True(result.HasColor); - Assert.Equal(128, result.Red); - Assert.Equal(0, result.Green); - Assert.Equal(128, result.Blue); - Assert.Equal("#800080", result.HexColor); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Sheet/SheetCommandsTests.Visibility.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Sheet/SheetCommandsTests.Visibility.cs deleted file mode 100644 index 1a462f57..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Sheet/SheetCommandsTests.Visibility.cs +++ /dev/null @@ -1,202 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Models; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Sheet; - -/// -/// Integration tests for worksheet visibility operations -/// -public partial class SheetCommandsTests -{ - /// - - [Fact] - public void SetVisibility_ToHidden_WorksCorrectly() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = $"Hide_{Guid.NewGuid():N}"[..31]; - _sheetCommands.Create(batch, sheetName); - - // Act - _sheetCommands.SetVisibility(batch, sheetName, SheetVisibility.Hidden); // SetVisibility throws on error - - // Assert - reaching here means set succeeded - - // Verify by reading visibility - var getResult = _sheetCommands.GetVisibility(batch, sheetName); - Assert.True(getResult.Success); - Assert.Equal(SheetVisibility.Hidden, getResult.Visibility); - Assert.Equal("Hidden", getResult.VisibilityName); - } - /// - - [Fact] - public void SetVisibility_ToVeryHidden_WorksCorrectly() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = $"VHide_{Guid.NewGuid():N}"[..31]; - _sheetCommands.Create(batch, sheetName); - - // Act - _sheetCommands.SetVisibility(batch, sheetName, SheetVisibility.VeryHidden); // SetVisibility throws on error - - // Assert - reaching here means set succeeded - - var getResult = _sheetCommands.GetVisibility(batch, sheetName); - Assert.True(getResult.Success); - Assert.Equal(SheetVisibility.VeryHidden, getResult.Visibility); - Assert.Equal("VeryHidden", getResult.VisibilityName); - } - /// - - [Fact] - public void Show_HiddenSheet_MakesVisible() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = $"ShowH_{Guid.NewGuid():N}"[..31]; - _sheetCommands.Create(batch, sheetName); - _sheetCommands.Hide(batch, sheetName); - - // Verify it's hidden - var hiddenCheck = _sheetCommands.GetVisibility(batch, sheetName); - Assert.Equal(SheetVisibility.Hidden, hiddenCheck.Visibility); - - // Act - Show the sheet - _sheetCommands.Show(batch, sheetName); // Show throws on error - - // Assert - reaching here means show succeeded - - var visibleCheck = _sheetCommands.GetVisibility(batch, sheetName); - Assert.Equal(SheetVisibility.Visible, visibleCheck.Visibility); - } - /// - - [Fact] - public void Show_VeryHiddenSheet_MakesVisible() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = $"ShowVH_{Guid.NewGuid():N}"[..31]; - _sheetCommands.Create(batch, sheetName); - _sheetCommands.VeryHide(batch, sheetName); - - // Verify it's very hidden - var veryHiddenCheck = _sheetCommands.GetVisibility(batch, sheetName); - Assert.Equal(SheetVisibility.VeryHidden, veryHiddenCheck.Visibility); - - // Act - Show the sheet - _sheetCommands.Show(batch, sheetName); // Show throws on error - - // Assert - reaching here means show succeeded - - var visibleCheck = _sheetCommands.GetVisibility(batch, sheetName); - Assert.Equal(SheetVisibility.Visible, visibleCheck.Visibility); - } - /// - - [Fact] - public void Hide_VisibleSheet_MakesHidden() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = $"HideMe_{Guid.NewGuid():N}"[..31]; - _sheetCommands.Create(batch, sheetName); - - // Act - _sheetCommands.Hide(batch, sheetName); // Hide throws on error - - // Assert - reaching here means hide succeeded - - var getResult = _sheetCommands.GetVisibility(batch, sheetName); - Assert.Equal(SheetVisibility.Hidden, getResult.Visibility); - } - /// - - [Fact] - public void VeryHide_VeryHidesVisibleSheet() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = $"VHide_{Guid.NewGuid():N}"[..31]; - _sheetCommands.Create(batch, sheetName); - - // Act - _sheetCommands.VeryHide(batch, sheetName); // VeryHide throws on error - - // Assert - reaching here means veryhide succeeded - - var getResult = _sheetCommands.GetVisibility(batch, sheetName); - Assert.Equal(SheetVisibility.VeryHidden, getResult.Visibility); - } - /// - - [Fact] - public void GetVisibility_ForVisibleSheet_ReturnsVisible() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = $"Vis_{Guid.NewGuid():N}"[..31]; - _sheetCommands.Create(batch, sheetName); - - // Act - var result = _sheetCommands.GetVisibility(batch, sheetName); - - // Assert - Assert.True(result.Success); - Assert.Equal(SheetVisibility.Visible, result.Visibility); - Assert.Equal("Visible", result.VisibilityName); - } - /// - - [Fact] - public void SetVisibility_WithNonExistentSheet_ThrowsException() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - - // Act & Assert - Should throw InvalidOperationException when sheet not found - var exception = Assert.Throws( - () => _sheetCommands.SetVisibility(batch, $"NonExist_{Guid.NewGuid():N}", SheetVisibility.Hidden)); - Assert.Contains("not found", exception.Message); - } - /// - - [Fact] - public void Visibility_CompleteWorkflow_AllLevelsWork() - { - // Arrange - using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath); - var sheetName = $"WFlow_{Guid.NewGuid():N}"[..31]; - _sheetCommands.Create(batch, sheetName); - - // Act & Assert - Test complete visibility workflow - - // Start visible - var check1 = _sheetCommands.GetVisibility(batch, sheetName); - Assert.Equal(SheetVisibility.Visible, check1.Visibility); - - // Hide it - _sheetCommands.Hide(batch, sheetName); - var check2 = _sheetCommands.GetVisibility(batch, sheetName); - Assert.Equal(SheetVisibility.Hidden, check2.Visibility); - - // Very hide it - _sheetCommands.VeryHide(batch, sheetName); - var check3 = _sheetCommands.GetVisibility(batch, sheetName); - Assert.Equal(SheetVisibility.VeryHidden, check3.Visibility); - - // Show it again - _sheetCommands.Show(batch, sheetName); - var check4 = _sheetCommands.GetVisibility(batch, sheetName); - Assert.Equal(SheetVisibility.Visible, check4.Visibility); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Sheet/SheetCommandsTests.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Sheet/SheetCommandsTests.cs deleted file mode 100644 index 7376d98b..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Sheet/SheetCommandsTests.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Sbroenne.ExcelMcp.Core.Commands; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Sheet; - -/// -/// Integration tests for Sheet lifecycle operations. -/// These tests require Excel installation and validate Core worksheet lifecycle management. -/// Tests use Core commands directly (not through CLI wrapper). -/// Single-workbook tests share one Excel file with unique sheets for isolation. -/// Cross-file tests (CopyToFile, MoveToFile) use their own file pairs. -/// Data operations (read, write, clear) moved to RangeCommandsTests. -/// -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("RequiresExcel", "true")] -[Trait("Feature", "Worksheets")] -public partial class SheetCommandsTests : IClassFixture -{ - private readonly SheetCommands _sheetCommands; - private readonly SheetTestsFixture _fixture; - - /// - /// Initializes a new instance of the class. - /// - public SheetCommandsTests(SheetTestsFixture fixture) - { - _sheetCommands = new SheetCommands(); - _fixture = fixture; - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Table/TableCommandsTests.BugRegression.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Table/TableCommandsTests.BugRegression.cs deleted file mode 100644 index e7f7f8a0..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Table/TableCommandsTests.BugRegression.cs +++ /dev/null @@ -1,84 +0,0 @@ -using System.Text.Json; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands.Table; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Table; - -/// -/// Bug regression tests for TableCommands. -/// These tests reproduce known bugs and must fail before the fix and pass after. -/// -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("RequiresExcel", "true")] -[Trait("Feature", "Tables")] -[Trait("Speed", "Medium")] -public sealed class TableCommandsTests_BugRegression : IClassFixture -{ - private readonly TableCommands _tableCommands; - private readonly TempDirectoryFixture _fixture; - - /// - /// Initializes a new instance of the class. - /// - public TableCommandsTests_BugRegression(TempDirectoryFixture fixture) - { - _tableCommands = new TableCommands(); - _fixture = fixture; - } - - /// - /// Regression test for issue #519: - /// table append throws COM marshalling exception when row values are JsonElement - /// (as produced by CLI JSON deserialization of --rows parameter). - /// Before fix: throws NotSupportedException / InvalidCastException / COMException. - /// After fix: appends rows successfully. - /// - [Fact] - public void Append_WithJsonElementValues_DoesNotThrow() - { - // Arrange: create a workbook with a table that has string, bool, and number columns - var testFile = CoreTestHelper.CreateUniqueTestFile( - nameof(TableCommandsTests_BugRegression), - nameof(Append_WithJsonElementValues_DoesNotThrow), - _fixture.TempDir, - ".xlsx"); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Create data + table in the same batch (no save needed) - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - sheet.Name = "Data"; - sheet.Range["A1"].Value2 = "Label"; - sheet.Range["B1"].Value2 = "IsActive"; - sheet.Range["C1"].Value2 = "Amount"; - sheet.Range["A2"].Value2 = "Initial"; - sheet.Range["B2"].Value2 = true; - sheet.Range["C2"].Value2 = 1.0; - return 0; - }); - _tableCommands.Create(batch, "Data", "DataTable", "A1:C2", true, "TableStyleLight1"); - - // Act: deserialize rows the same way the CLI does — via JsonSerializer producing JsonElement - // This is key: the values must be JsonElement (boxed as object?), not raw C# types - var rowsJson = """[["NewRow", true, 99.5], ["AnotherRow", false, 0.0]]"""; - var deserializedRows = JsonSerializer.Deserialize>>(rowsJson)!; - - // Confirm the test is correctly structured: values must be JsonElements, not strings/bools - Assert.IsType(deserializedRows[0][0]); - Assert.IsType(deserializedRows[0][1]); - Assert.IsType(deserializedRows[0][2]); - - // Assert: should not throw — before the fix this throws a COM marshalling exception - _tableCommands.Append(batch, "DataTable", deserializedRows); - - // Verify rows were appended - var info = _tableCommands.Read(batch, "DataTable"); - Assert.True(info.Success, $"Read after append failed: {info.ErrorMessage}"); - Assert.Equal(3, info.Table!.RowCount); // 1 original + 2 appended - } -} diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Table/TableCommandsTests.DataModel.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Table/TableCommandsTests.DataModel.cs deleted file mode 100644 index 825b9919..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Table/TableCommandsTests.DataModel.cs +++ /dev/null @@ -1,187 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands.Range; -using Sbroenne.ExcelMcp.Core.Commands.Table; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Table; - -/// -/// Integration tests for TableCommands.AddToDataModel, focusing on bracket column name detection -/// and stripping. Regression tests for the stripBracketColumnNames feature. -/// -public class TableCommandsDataModelTests : IClassFixture -{ - private readonly TableCommands _tableCommands; - private readonly RangeCommands _rangeCommands; - private readonly TempDirectoryFixture _fixture; - - public TableCommandsDataModelTests(TempDirectoryFixture fixture) - { - _tableCommands = new TableCommands(); - _rangeCommands = new RangeCommands(); - _fixture = fixture; - } - - private static string CreateTableWithBracketColumns(string filePath) - { - using var batch = ExcelSession.BeginBatch(filePath); - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - sheet.Name = "Data"; - // Column A: normal name, Column B/C: bracket names - sheet.Range["A1"].Value2 = "ProductName"; - sheet.Range["B1"].Value2 = "[ACR_CM1]"; - sheet.Range["C1"].Value2 = "[ACR_CM2]"; - sheet.Range["A2"].Value2 = "Widget"; - sheet.Range["B2"].Value2 = 100.0; - sheet.Range["C2"].Value2 = 200.0; - sheet.Range["A3"].Value2 = "Gadget"; - sheet.Range["B3"].Value2 = 150.0; - sheet.Range["C3"].Value2 = 250.0; - }); - var tableResult = new TableCommands().Create(batch, "Data", "BracketTable", "A1:C3"); - Assert.True(tableResult.Success, $"Setup failed: {tableResult.ErrorMessage}"); - batch.Save(); - return "BracketTable"; - } - - private static string CreateTableWithNormalColumns(string filePath) - { - using var batch = ExcelSession.BeginBatch(filePath); - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - sheet.Name = "Data"; - sheet.Range["A1"].Value2 = "ProductName"; - sheet.Range["B1"].Value2 = "Amount"; - sheet.Range["A2"].Value2 = "Widget"; - sheet.Range["B2"].Value2 = 100.0; - }); - var tableResult = new TableCommands().Create(batch, "Data", "NormalTable", "A1:B2"); - Assert.True(tableResult.Success, $"Setup failed: {tableResult.ErrorMessage}"); - batch.Save(); - return "NormalTable"; - } - - /// - /// When a table has bracket column names and stripBracketColumnNames=false, - /// BracketColumnsFound should be populated with the bracket column names. - /// - [Fact] - [Trait("Layer", "Core")] - [Trait("Category", "Integration")] - [Trait("Feature", "Table")] - [Trait("Feature", "DataModel")] - [Trait("RequiresExcel", "true")] - [Trait("Speed", "Medium")] - public void AddToDataModel_BracketColumns_WithoutStrip_ReturnsBracketColumnsFound() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - CreateTableWithBracketColumns(testFile); - - // Act - using var batch = ExcelSession.BeginBatch(testFile); - var result = _tableCommands.AddToDataModel(batch, "BracketTable", stripBracketColumnNames: false); - - // Assert - Assert.True(result.Success, $"AddToDataModel failed: {result.ErrorMessage}"); - Assert.NotNull(result.BracketColumnsFound); - Assert.Equal(2, result.BracketColumnsFound.Length); - Assert.Contains("[ACR_CM1]", result.BracketColumnsFound); - Assert.Contains("[ACR_CM2]", result.BracketColumnsFound); - Assert.Null(result.BracketColumnsRenamed); - } - - /// - /// When a table has bracket column names and stripBracketColumnNames=true, - /// the columns should be renamed and BracketColumnsRenamed populated. - /// - [Fact] - [Trait("Layer", "Core")] - [Trait("Category", "Integration")] - [Trait("Feature", "Table")] - [Trait("Feature", "DataModel")] - [Trait("RequiresExcel", "true")] - [Trait("Speed", "Medium")] - public void AddToDataModel_BracketColumns_WithStrip_RenamesColumnsAndReturnsRenamed() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - CreateTableWithBracketColumns(testFile); - - // Act - using var batch = ExcelSession.BeginBatch(testFile); - var result = _tableCommands.AddToDataModel(batch, "BracketTable", stripBracketColumnNames: true); - - // Assert - Assert.True(result.Success, $"AddToDataModel failed: {result.ErrorMessage}"); - Assert.NotNull(result.BracketColumnsRenamed); - Assert.Equal(2, result.BracketColumnsRenamed.Length); - Assert.Contains("[ACR_CM1]", result.BracketColumnsRenamed); - Assert.Contains("[ACR_CM2]", result.BracketColumnsRenamed); - Assert.Null(result.BracketColumnsFound); - - // Verify the source column headers were actually renamed (brackets removed) - var rangeResult = _rangeCommands.GetValues(batch, "Data", "A1:C1"); - Assert.NotNull(rangeResult); - Assert.Equal("ProductName", rangeResult.Values[0][0]?.ToString()); - Assert.Equal("ACR_CM1", rangeResult.Values[0][1]?.ToString()); - Assert.Equal("ACR_CM2", rangeResult.Values[0][2]?.ToString()); - } - - /// - /// When a table has no bracket column names, BracketColumnsFound and BracketColumnsRenamed - /// should both be null. - /// - [Fact] - [Trait("Layer", "Core")] - [Trait("Category", "Integration")] - [Trait("Feature", "Table")] - [Trait("Feature", "DataModel")] - [Trait("RequiresExcel", "true")] - [Trait("Speed", "Medium")] - public void AddToDataModel_NoBracketColumns_NoBracketFields() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - CreateTableWithNormalColumns(testFile); - - // Act - using var batch = ExcelSession.BeginBatch(testFile); - var result = _tableCommands.AddToDataModel(batch, "NormalTable", stripBracketColumnNames: false); - - // Assert - Assert.True(result.Success, $"AddToDataModel failed: {result.ErrorMessage}"); - Assert.Null(result.BracketColumnsFound); - Assert.Null(result.BracketColumnsRenamed); - } - - /// - /// Adding the same table to the Data Model twice should throw InvalidOperationException. - /// - [Fact] - [Trait("Layer", "Core")] - [Trait("Category", "Integration")] - [Trait("Feature", "Table")] - [Trait("Feature", "DataModel")] - [Trait("RequiresExcel", "true")] - [Trait("Speed", "Medium")] - public void AddToDataModel_AlreadyInModel_ThrowsInvalidOperationException() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - CreateTableWithNormalColumns(testFile); - - using var batch = ExcelSession.BeginBatch(testFile); - - // First add succeeds - var first = _tableCommands.AddToDataModel(batch, "NormalTable"); - Assert.True(first.Success, $"First AddToDataModel failed: {first.ErrorMessage}"); - - // Second add should throw (table already in model) - Assert.ThrowsAny(() => _tableCommands.AddToDataModel(batch, "NormalTable")); - } -} diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Table/TableCommandsTests.Dax.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Table/TableCommandsTests.Dax.cs deleted file mode 100644 index 413bba6b..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Table/TableCommandsTests.Dax.cs +++ /dev/null @@ -1,347 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands.Table; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Table; - -/// -/// Integration tests for DAX-backed Table operations (create-from-dax, update-dax, get-dax). -/// Tests verify that Excel Tables can be created from DAX EVALUATE queries and their queries updated. -/// Uses DataModelPivotTableFixture which provides Data Model tables for DAX queries. -/// -[Collection("DataModel")] -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("RequiresExcel", "true")] -[Trait("Feature", "Tables")] -[Trait("Speed", "Slow")] -public class TableCommandsTests_Dax -{ - private readonly TableCommands _tableCommands; - private readonly string _dataModelFile; - - public TableCommandsTests_Dax(DataModelPivotTableFixture fixture) - { - _tableCommands = new TableCommands(); - _dataModelFile = fixture.TestFilePath; - } - - #region CreateFromDax Tests - - /// - /// Tests creating a DAX-backed table with a simple EVALUATE query. - /// LLM use case: "create a table from this DAX query" - /// - [Fact] - public void CreateFromDax_SimpleEvaluateQuery_CreatesTable() - { - var tableName = $"DaxTable_{Guid.NewGuid():N}"; - - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - // Create a DAX-backed table - _tableCommands.CreateFromDax( - batch, - "Sheet1", // Use existing sheet or create if needed - tableName, - "EVALUATE 'SalesTable'", - "A1"); - - // Verify table was created - var listResult = _tableCommands.List(batch); - Assert.True(listResult.Success, $"List failed: {listResult.ErrorMessage}"); - Assert.Contains(listResult.Tables, t => t.Name == tableName); - - // Verify GetDax shows it's a DAX-backed table - var daxInfo = _tableCommands.GetDax(batch, tableName); - Assert.True(daxInfo.Success, $"GetDax failed: {daxInfo.ErrorMessage}"); - Assert.True(daxInfo.HasDaxConnection, "Expected table to have DAX connection"); - Assert.NotNull(daxInfo.DaxQuery); - Assert.NotEmpty(daxInfo.DaxQuery!); - } - - /// - /// Tests creating a DAX-backed table with SUMMARIZE aggregation. - /// LLM use case: "create a summary table with totals by customer" - /// - [Fact] - public void CreateFromDax_SummarizeQuery_CreatesAggregatedTable() - { - var tableName = $"SummaryTable_{Guid.NewGuid():N}"; - var daxQuery = "EVALUATE SUMMARIZE('SalesTable', 'SalesTable'[CustomerID], \"TotalAmount\", SUM('SalesTable'[Amount]))"; - - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - // Create a DAX-backed table with SUMMARIZE - _tableCommands.CreateFromDax( - batch, - "Sheet1", - tableName, - daxQuery, - "A1"); - - // Verify table was created - var readResult = _tableCommands.Read(batch, tableName); - Assert.True(readResult.Success, $"Read failed: {readResult.ErrorMessage}"); - Assert.NotNull(readResult.Table); - - // GetDax should return the query - var daxInfo = _tableCommands.GetDax(batch, tableName); - Assert.True(daxInfo.HasDaxConnection); - } - - /// - /// Tests creating a DAX-backed table with FILTER. - /// LLM use case: "create a filtered view of the data" - /// - [Fact] - public void CreateFromDax_FilterQuery_CreatesFilteredTable() - { - var tableName = $"FilteredTable_{Guid.NewGuid():N}"; - var daxQuery = "EVALUATE FILTER('SalesTable', 'SalesTable'[Amount] > 100)"; - - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - _tableCommands.CreateFromDax( - batch, - "Sheet1", - tableName, - daxQuery, - "A1"); - - var listResult = _tableCommands.List(batch); - Assert.Contains(listResult.Tables, t => t.Name == tableName); - } - - /// - /// Tests creating DAX table with custom target cell. - /// - [Fact] - public void CreateFromDax_CustomTargetCell_PlacesTableCorrectly() - { - var tableName = $"OffsetTable_{Guid.NewGuid():N}"; - - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - // Create table starting at C5 - _tableCommands.CreateFromDax( - batch, - "Sheet1", - tableName, - "EVALUATE 'CustomersTable'", - "C5"); - - // Verify table exists - var listResult = _tableCommands.List(batch); - Assert.Contains(listResult.Tables, t => t.Name == tableName); - - // Verify table position (Read should show the range) - var readResult = _tableCommands.Read(batch, tableName); - Assert.True(readResult.Success); - Assert.NotNull(readResult.Table?.Range); - // Range should include C5 - Assert.Contains("C", readResult.Table!.Range, StringComparison.OrdinalIgnoreCase); - } - - #endregion - - #region UpdateDax Tests - - /// - /// Tests updating the DAX query of an existing DAX-backed table. - /// LLM use case: "change the filter on this DAX table" - /// - [Fact] - public void UpdateDax_ExistingDaxTable_UpdatesQuery() - { - var tableName = $"UpdateDaxTable_{Guid.NewGuid():N}"; - var originalQuery = "EVALUATE 'SalesTable'"; - var updatedQuery = "EVALUATE FILTER('SalesTable', 'SalesTable'[CustomerID] = 1)"; - - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - // Create initial DAX table - _tableCommands.CreateFromDax(batch, "Sheet1", tableName, originalQuery, "A1"); - - // Update the DAX query - _tableCommands.UpdateDax(batch, tableName, updatedQuery); - - // Verify the query was updated - var daxInfo = _tableCommands.GetDax(batch, tableName); - Assert.True(daxInfo.Success); - Assert.Contains("FILTER", daxInfo.DaxQuery, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Tests that UpdateDax fails for non-DAX tables. - /// - [Fact] - public void UpdateDax_NonDaxTable_ThrowsError() - { - // SalesTable from fixture is a regular table, not DAX-backed - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - // Attempting to update a non-DAX table should throw some kind of exception - // (could be InvalidOperationException from our validation or COMException from COM) - var ex = Assert.ThrowsAny(() => - _tableCommands.UpdateDax(batch, "SalesTable", "EVALUATE 'ProductsTable'")); - - Assert.NotNull(ex); - } - - /// - /// Tests UpdateDax with invalid DAX syntax. - /// - [Fact] - public void UpdateDax_InvalidDax_ThrowsError() - { - var tableName = $"UpdateErrorTable_{Guid.NewGuid():N}"; - - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - // Create initial DAX table - _tableCommands.CreateFromDax(batch, "Sheet1", tableName, "EVALUATE 'SalesTable'", "A1"); - - // Try to update with invalid DAX - should throw - var ex = Assert.ThrowsAny(() => - _tableCommands.UpdateDax(batch, tableName, "EVALUATE INVALID_SYNTAX()")); - - Assert.NotNull(ex); - } - - #endregion - - #region GetDax Tests - - /// - /// Tests GetDax on a DAX-backed table returns query info. - /// LLM use case: "what DAX query is this table using?" - /// - [Fact] - public void GetDax_DaxBackedTable_ReturnsQueryInfo() - { - var tableName = $"GetDaxTable_{Guid.NewGuid():N}"; - var daxQuery = "EVALUATE TOPN(10, 'SalesTable', 'SalesTable'[Amount], DESC)"; - - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - // Create DAX table - _tableCommands.CreateFromDax(batch, "Sheet1", tableName, daxQuery, "A1"); - - // Get DAX info - var result = _tableCommands.GetDax(batch, tableName); - - Assert.True(result.Success, $"GetDax failed: {result.ErrorMessage}"); - Assert.Equal(tableName, result.TableName); - Assert.True(result.HasDaxConnection); - Assert.NotNull(result.DaxQuery); - Assert.Contains("TOPN", result.DaxQuery!, StringComparison.OrdinalIgnoreCase); - Assert.NotNull(result.ModelConnectionName); - Assert.NotEmpty(result.ModelConnectionName!); - } - - /// - /// Tests GetDax on a regular (non-DAX) table returns HasDaxConnection = false. - /// LLM use case: "check if this table is DAX-backed" - /// - [Fact] - public void GetDax_RegularTable_ReturnsNoDaxConnection() - { - // SalesTable from fixture is a regular table loaded to Data Model - // but it's not backed by a DAX query - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - var result = _tableCommands.GetDax(batch, "SalesTable"); - - Assert.True(result.Success); - Assert.Equal("SalesTable", result.TableName); - Assert.False(result.HasDaxConnection); - Assert.True(string.IsNullOrEmpty(result.DaxQuery)); - } - - /// - /// Tests GetDax on non-existent table throws error. - /// - [Fact] - public void GetDax_NonExistentTable_ThrowsError() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - var ex = Assert.ThrowsAny(() => - _tableCommands.GetDax(batch, "NonExistentTable_12345")); - - Assert.NotNull(ex); - } - - #endregion - - #region Parameter Validation Tests - - /// - /// Tests CreateFromDax with null sheetName throws ArgumentException. - /// - [Fact] - public void CreateFromDax_NullSheetName_ThrowsArgumentException() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - var ex = Assert.Throws(() => - _tableCommands.CreateFromDax(batch, null!, "TestTable", "EVALUATE 'Sales'")); - - Assert.Contains("sheetName", ex.Message); - } - - /// - /// Tests CreateFromDax with null tableName throws ArgumentException. - /// - [Fact] - public void CreateFromDax_NullTableName_ThrowsArgumentException() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - var ex = Assert.Throws(() => - _tableCommands.CreateFromDax(batch, "Sheet1", null!, "EVALUATE 'Sales'")); - - Assert.Contains("tableName", ex.Message); - } - - /// - /// Tests CreateFromDax with null daxQuery throws ArgumentException. - /// - [Fact] - public void CreateFromDax_NullDaxQuery_ThrowsArgumentException() - { - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - var ex = Assert.Throws(() => - _tableCommands.CreateFromDax(batch, "Sheet1", "TestTable", null!)); - - Assert.Contains("daxQuery", ex.Message); - } - - /// - /// Tests UpdateDax with null daxQuery throws ArgumentException. - /// - [Fact] - public void UpdateDax_NullDaxQuery_ThrowsArgumentException() - { - var tableName = $"UpdateNullTable_{Guid.NewGuid():N}"; - - using var batch = ExcelSession.BeginBatch(_dataModelFile); - - // Create table first - _tableCommands.CreateFromDax(batch, "Sheet1", tableName, "EVALUATE 'SalesTable'", "A1"); - - var ex = Assert.Throws(() => - _tableCommands.UpdateDax(batch, tableName, null!)); - - Assert.Contains("daxQuery", ex.Message); - } - - #endregion -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Table/TableCommandsTests.Slicers.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Table/TableCommandsTests.Slicers.cs deleted file mode 100644 index 4a103baa..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Table/TableCommandsTests.Slicers.cs +++ /dev/null @@ -1,478 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Table; - -/// -/// Integration tests for Table Slicer operations. -/// Tests cover: create, list, set selection, delete slicers for Excel Tables. -/// Uses TableTestsFixture which creates isolated table files per test. -/// -public partial class TableCommandsTests -{ - #region Table Slicer Tests - - /// - /// Tests creating a slicer for a Table column. - /// - [Fact] - public void CreateTableSlicer_ValidColumn_CreatesSlicerSuccessfully() - { - // Arrange - Create a fresh test file with SalesTable - var testFile = _fixture.CreateModificationTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - Create slicer for Region column - var slicerResult = _tableCommands.CreateTableSlicer( - batch, - tableName: "SalesTable", - columnName: "Region", - slicerName: "RegionSlicer", - destinationSheet: "Sales", - position: "F2"); - - // Assert - Assert.True(slicerResult.Success, $"CreateTableSlicer failed: {slicerResult.ErrorMessage}"); - Assert.Equal("RegionSlicer", slicerResult.Name); - Assert.Equal("Region", slicerResult.FieldName); - Assert.Equal("Sales", slicerResult.SheetName); - Assert.NotNull(slicerResult.AvailableItems); - Assert.Contains("North", slicerResult.AvailableItems); - Assert.Contains("South", slicerResult.AvailableItems); - Assert.Contains("East", slicerResult.AvailableItems); - Assert.Contains("West", slicerResult.AvailableItems); - Assert.Equal("SalesTable", slicerResult.ConnectedTable); - Assert.Equal("Table", slicerResult.SourceType); - Assert.NotNull(slicerResult.WorkflowHint); - } - - /// - /// Tests listing Table slicers in a workbook with no filter. - /// - [Fact] - public void ListTableSlicers_WithSlicers_ReturnsAllSlicers() - { - // Arrange - var testFile = _fixture.CreateModificationTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - // Create two slicers - var slicer1Result = _tableCommands.CreateTableSlicer( - batch, "SalesTable", "Region", "RegionSlicer1", "Sales", "F2"); - Assert.True(slicer1Result.Success, $"Failed to create slicer 1: {slicer1Result.ErrorMessage}"); - - var slicer2Result = _tableCommands.CreateTableSlicer( - batch, "SalesTable", "Product", "ProductSlicer1", "Sales", "F10"); - Assert.True(slicer2Result.Success, $"Failed to create slicer 2: {slicer2Result.ErrorMessage}"); - - // Act - var listResult = _tableCommands.ListTableSlicers(batch); - - // Assert - Assert.True(listResult.Success, $"ListTableSlicers failed: {listResult.ErrorMessage}"); - Assert.NotNull(listResult.Slicers); - Assert.True(listResult.Slicers.Count >= 2, $"Expected at least 2 slicers, got {listResult.Slicers.Count}"); - Assert.Contains(listResult.Slicers, s => s.Name == "RegionSlicer1"); - Assert.Contains(listResult.Slicers, s => s.Name == "ProductSlicer1"); - } - - /// - /// Tests listing slicers filtered by Table name. - /// - [Fact] - public void ListTableSlicers_FilterByTable_ReturnsConnectedSlicersOnly() - { - // Arrange - var testFile = _fixture.CreateModificationTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - // Create slicer for SalesTable - var slicerResult = _tableCommands.CreateTableSlicer( - batch, "SalesTable", "Region", "FilterRegionSlicer", "Sales", "F2"); - Assert.True(slicerResult.Success, $"Failed to create slicer: {slicerResult.ErrorMessage}"); - - // Act - var listResult = _tableCommands.ListTableSlicers(batch, tableName: "SalesTable"); - - // Assert - Assert.True(listResult.Success, $"ListTableSlicers failed: {listResult.ErrorMessage}"); - Assert.NotNull(listResult.Slicers); - Assert.Single(listResult.Slicers); - Assert.Equal("FilterRegionSlicer", listResult.Slicers[0].Name); - Assert.Equal("SalesTable", listResult.Slicers[0].ConnectedTable); - } - - /// - /// Tests setting Table slicer selection to specific items. - /// - [Fact] - public void SetTableSlicerSelection_SpecificItems_SelectsOnlyThoseItems() - { - // Arrange - var testFile = _fixture.CreateModificationTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - // Create slicer - var slicerResult = _tableCommands.CreateTableSlicer( - batch, "SalesTable", "Region", "SelectionSlicer", "Sales", "F2"); - Assert.True(slicerResult.Success, $"Failed to create slicer: {slicerResult.ErrorMessage}"); - - // Act - Select only "North" and "South" - var selectionResult = _tableCommands.SetTableSlicerSelection( - batch, "SelectionSlicer", new List { "North", "South" }, clearFirst: true); - - // Assert - Assert.True(selectionResult.Success, $"SetTableSlicerSelection failed: {selectionResult.ErrorMessage}"); - Assert.NotNull(selectionResult.SelectedItems); - Assert.Equal(2, selectionResult.SelectedItems.Count); - Assert.Contains("North", selectionResult.SelectedItems); - Assert.Contains("South", selectionResult.SelectedItems); - Assert.DoesNotContain("East", selectionResult.SelectedItems); - Assert.DoesNotContain("West", selectionResult.SelectedItems); - Assert.NotNull(selectionResult.WorkflowHint); - } - - /// - /// Tests clearing Table slicer selection (selecting all items). - /// - [Fact] - public void SetTableSlicerSelection_EmptyList_ClearsFilterSelectsAll() - { - // Arrange - var testFile = _fixture.CreateModificationTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - // Create slicer - var slicerResult = _tableCommands.CreateTableSlicer( - batch, "SalesTable", "Region", "ClearFilterSlicer", "Sales", "F2"); - Assert.True(slicerResult.Success, $"Failed to create slicer: {slicerResult.ErrorMessage}"); - - // First, filter to just "North" - _tableCommands.SetTableSlicerSelection(batch, "ClearFilterSlicer", new List { "North" }); - - // Act - Clear filter by passing empty list - var clearResult = _tableCommands.SetTableSlicerSelection( - batch, "ClearFilterSlicer", new List()); - - // Assert - Assert.True(clearResult.Success, $"SetTableSlicerSelection (clear) failed: {clearResult.ErrorMessage}"); - Assert.NotNull(clearResult.SelectedItems); - Assert.True(clearResult.SelectedItems.Count >= 4, "Expected all items to be selected after clear"); - Assert.Contains("North", clearResult.SelectedItems); - Assert.Contains("South", clearResult.SelectedItems); - Assert.Contains("East", clearResult.SelectedItems); - Assert.Contains("West", clearResult.SelectedItems); - Assert.Contains("cleared", clearResult.WorkflowHint, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Tests deleting a Table slicer from the workbook. - /// - [Fact] - public void DeleteTableSlicer_ExistingSlicer_RemovesSuccessfully() - { - // Arrange - var testFile = _fixture.CreateModificationTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - // Create slicer - var slicerResult = _tableCommands.CreateTableSlicer( - batch, "SalesTable", "Region", "SlicerToDelete", "Sales", "F2"); - Assert.True(slicerResult.Success, $"Failed to create slicer: {slicerResult.ErrorMessage}"); - - // Verify slicer exists - var listBeforeResult = _tableCommands.ListTableSlicers(batch); - Assert.Contains(listBeforeResult.Slicers, s => s.Name == "SlicerToDelete"); - - // Act - var deleteResult = _tableCommands.DeleteTableSlicer(batch, "SlicerToDelete"); - - // Assert - Assert.True(deleteResult.Success, $"DeleteTableSlicer failed: {deleteResult.ErrorMessage}"); - - // Verify slicer is gone - var listAfterResult = _tableCommands.ListTableSlicers(batch); - Assert.DoesNotContain(listAfterResult.Slicers, s => s.Name == "SlicerToDelete"); - } - - /// - /// Tests deleting a non-existent Table slicer returns error. - /// - [Fact] - public void DeleteTableSlicer_NonExistentSlicer_ReturnsError() - { - // Arrange - var testFile = _fixture.CreateModificationTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - Try to delete a slicer that doesn't exist - var deleteResult = _tableCommands.DeleteTableSlicer(batch, "NonExistentSlicer"); - - // Assert - Assert.False(deleteResult.Success); - Assert.Contains("not found", deleteResult.ErrorMessage, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Tests setting Table slicer selection for non-existent slicer returns error. - /// - [Fact] - public void SetTableSlicerSelection_NonExistentSlicer_ReturnsError() - { - // Arrange - var testFile = _fixture.CreateModificationTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - var selectionResult = _tableCommands.SetTableSlicerSelection( - batch, "NonExistentSlicer", new List { "North" }); - - // Assert - Assert.False(selectionResult.Success); - Assert.Contains("not found", selectionResult.ErrorMessage, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Tests listing Table slicers when workbook has no slicers. - /// - [Fact] - public void ListTableSlicers_NoSlicers_ReturnsEmptyList() - { - // Arrange - Fresh file with no slicers - var testFile = _fixture.CreateModificationTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - var listResult = _tableCommands.ListTableSlicers(batch); - - // Assert - Assert.True(listResult.Success, $"ListTableSlicers failed: {listResult.ErrorMessage}"); - Assert.NotNull(listResult.Slicers); - Assert.Empty(listResult.Slicers); - } - - /// - /// Tests creating slicer for invalid Table column returns error. - /// - [Fact] - public void CreateTableSlicer_InvalidColumn_ReturnsError() - { - // Arrange - var testFile = _fixture.CreateModificationTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - Try to create slicer for non-existent column - var slicerResult = _tableCommands.CreateTableSlicer( - batch, "SalesTable", "NonExistentColumn", "InvalidSlicer", "Sales", "F2"); - - // Assert - Assert.False(slicerResult.Success); - Assert.Contains("Column 'NonExistentColumn' not found", slicerResult.ErrorMessage, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Tests creating slicer for invalid Table name throws exception. - /// - [Fact] - public void CreateTableSlicer_InvalidTableName_ReturnsError() - { - // Arrange - var testFile = _fixture.CreateModificationTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - // Act & Assert - expects exception when table not found - var ex = Assert.Throws(() => - _tableCommands.CreateTableSlicer( - batch, "NonExistentTable", "Region", "InvalidSlicer", "Sales", "F2")); - Assert.Contains("not found", ex.Message, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Tests that Table slicer shows connected Table info. - /// - [Fact] - public void CreateTableSlicer_ShowsConnectedTable() - { - // Arrange - var testFile = _fixture.CreateModificationTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - Create slicer - var slicerResult = _tableCommands.CreateTableSlicer( - batch, "SalesTable", "Region", "ConnectedSlicer", "Sales", "F2"); - - // Assert - Assert.True(slicerResult.Success, $"CreateTableSlicer failed: {slicerResult.ErrorMessage}"); - Assert.NotNull(slicerResult.ConnectedTable); - Assert.Equal("SalesTable", slicerResult.ConnectedTable); - Assert.Equal("Table", slicerResult.SourceType); - } - - /// - /// Tests that slicer Position is returned as a valid cell reference. - /// This test catches bugs where Position is empty due to incorrect COM API usage. - /// Bug context: TopLeftCell is on Slicer.Shape, not Slicer directly. - /// - [Fact] - public void CreateTableSlicer_ReturnsValidPosition() - { - // Arrange - var testFile = _fixture.CreateModificationTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - Create slicer at F2 - var slicerResult = _tableCommands.CreateTableSlicer( - batch, "SalesTable", "Region", "PositionTestSlicer", "Sales", "F2"); - - // Assert - Position must be a valid cell reference, not empty - Assert.True(slicerResult.Success, $"CreateTableSlicer failed: {slicerResult.ErrorMessage}"); - Assert.False(string.IsNullOrEmpty(slicerResult.Position), - "Slicer Position should not be empty - verify Shape.TopLeftCell API is used correctly"); - Assert.Matches(@"^[A-Z]+\d+$", slicerResult.Position); // e.g., "F2", "AA10" - } - - /// - /// Tests that ListTableSlicers returns valid Position for each slicer. - /// - [Fact] - public void ListTableSlicers_ReturnsValidPositionForEachSlicer() - { - // Arrange - var testFile = _fixture.CreateModificationTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - // Create slicers at different positions - _tableCommands.CreateTableSlicer(batch, "SalesTable", "Region", "ListPosSlicer1", "Sales", "F2"); - _tableCommands.CreateTableSlicer(batch, "SalesTable", "Product", "ListPosSlicer2", "Sales", "H2"); - - // Act - var listResult = _tableCommands.ListTableSlicers(batch); - - // Assert - All slicers should have valid positions - Assert.True(listResult.Success, $"ListTableSlicers failed: {listResult.ErrorMessage}"); - foreach (var slicer in listResult.Slicers) - { - Assert.False(string.IsNullOrEmpty(slicer.Position), - $"Slicer '{slicer.Name}' has empty Position - verify Shape.TopLeftCell API"); - } - } - - /// - /// Tests that FieldName is returned correctly (not "Unknown"). - /// This test catches bugs where SourceName property access fails silently. - /// - [Fact] - public void CreateTableSlicer_ReturnsCorrectFieldName() - { - // Arrange - var testFile = _fixture.CreateModificationTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - Create slicer for "Region" column - var slicerResult = _tableCommands.CreateTableSlicer( - batch, "SalesTable", "Region", "FieldNameTestSlicer", "Sales", "F2"); - - // Assert - FieldName must match the column name, not be "Unknown" - Assert.True(slicerResult.Success, $"CreateTableSlicer failed: {slicerResult.ErrorMessage}"); - Assert.NotEqual("Unknown", slicerResult.FieldName); - Assert.Equal("Region", slicerResult.FieldName); - } - - /// - /// Tests that ConnectedTable is returned correctly (not "Unknown" or empty). - /// This test catches bugs where ListObject property access fails silently. - /// - [Fact] - public void ListTableSlicers_ReturnsCorrectConnectedTable() - { - // Arrange - var testFile = _fixture.CreateModificationTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - _tableCommands.CreateTableSlicer(batch, "SalesTable", "Region", "ConnTableTestSlicer", "Sales", "F2"); - - // Act - var listResult = _tableCommands.ListTableSlicers(batch); - - // Assert - ConnectedTable must be the actual table name - Assert.True(listResult.Success, $"ListTableSlicers failed: {listResult.ErrorMessage}"); - var slicer = listResult.Slicers.FirstOrDefault(s => s.Name == "ConnTableTestSlicer"); - Assert.NotNull(slicer); - Assert.NotEqual("Unknown", slicer.ConnectedTable); - Assert.NotEqual(string.Empty, slicer.ConnectedTable); - Assert.Equal("SalesTable", slicer.ConnectedTable); - } - - /// - /// Tests rapid sequential operations: create slicer, then immediately list slicers. - /// This mimics MCP/LLM patterns where operations are called in rapid succession. - /// Tests for timing issues and COM object availability. - /// - [Fact] - public void RapidSequentialOperations_CreateThenList_ReturnsValidPosition() - { - // Arrange - var testFile = _fixture.CreateModificationTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - Create slicer then IMMEDIATELY list (mimics MCP agent pattern) - var createResult = _tableCommands.CreateTableSlicer( - batch, "SalesTable", "Region", "RapidTestSlicer", "Sales", "F2"); - Assert.True(createResult.Success, $"CreateTableSlicer failed: {createResult.ErrorMessage}"); - - // Immediately call list - no delay (this is how MCP agents work) - var listResult = _tableCommands.ListTableSlicers(batch); - - // Assert - Both operations must succeed with valid data - Assert.True(listResult.Success, $"ListTableSlicers failed: {listResult.ErrorMessage}"); - var slicer = listResult.Slicers.FirstOrDefault(s => s.Name == "RapidTestSlicer"); - Assert.NotNull(slicer); - Assert.False(string.IsNullOrEmpty(slicer.Position), - "Slicer Position empty after rapid create+list - possible COM timing issue"); - Assert.NotEqual("Unknown", slicer.FieldName); - Assert.Equal("SalesTable", slicer.ConnectedTable); - } - - /// - /// Tests multiple rapid operations in sequence to stress test COM object handling. - /// Create multiple slicers, then list all, then set selection on each. - /// - [Fact] - public void RapidSequentialOperations_MultipleSlicers_AllReturnValidData() - { - // Arrange - var testFile = _fixture.CreateModificationTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - Create 3 slicers in rapid succession (using columns that exist in test data) - var slicer1 = _tableCommands.CreateTableSlicer(batch, "SalesTable", "Region", "RapidSlicer1", "Sales", "F2"); - var slicer2 = _tableCommands.CreateTableSlicer(batch, "SalesTable", "Product", "RapidSlicer2", "Sales", "H2"); - var slicer3 = _tableCommands.CreateTableSlicer(batch, "SalesTable", "Amount", "RapidSlicer3", "Sales", "J2"); - - Assert.True(slicer1.Success, $"CreateTableSlicer 1 failed: {slicer1.ErrorMessage}"); - Assert.True(slicer2.Success, $"CreateTableSlicer 2 failed: {slicer2.ErrorMessage}"); - Assert.True(slicer3.Success, $"CreateTableSlicer 3 failed: {slicer3.ErrorMessage}"); - - // Immediately list all slicers - var listResult = _tableCommands.ListTableSlicers(batch); - - // Assert - All 3 slicers must have valid data - Assert.True(listResult.Success, $"ListTableSlicers failed: {listResult.ErrorMessage}"); - Assert.True(listResult.Slicers.Count >= 3, $"Expected at least 3 slicers, got {listResult.Slicers.Count}"); - - foreach (var name in new[] { "RapidSlicer1", "RapidSlicer2", "RapidSlicer3" }) - { - var slicer = listResult.Slicers.FirstOrDefault(s => s.Name == name); - Assert.NotNull(slicer); - Assert.False(string.IsNullOrEmpty(slicer.Position), - $"Slicer '{name}' has empty Position after rapid operations"); - Assert.NotEqual("Unknown", slicer.FieldName); - } - } - - #endregion -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Table/TableCommandsTests.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Table/TableCommandsTests.cs deleted file mode 100644 index b8f4fad8..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Table/TableCommandsTests.cs +++ /dev/null @@ -1,472 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Commands.Range; -using Sbroenne.ExcelMcp.Core.Commands.Table; -using Sbroenne.ExcelMcp.Core.Models; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Table; - -/// -/// Integration tests for Table operations focusing on LLM use cases. -/// Tests cover essential workflows: create, list, info, delete, rename, resize, columns, filters, totals. -/// Uses TableTestsFixture which creates ONE Table file per test class. -/// Modification tests use CreateModificationTestFile() for isolated files. -/// -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("RequiresExcel", "true")] -[Trait("Feature", "Tables")] -[Trait("Speed", "Medium")] -public partial class TableCommandsTests : IClassFixture -{ - private readonly TableCommands _tableCommands; - private readonly IRangeCommands _rangeCommands; - private readonly TableTestsFixture _fixture; - private readonly string _tableFile; - private readonly TableCreationResult _creationResult; - - /// - /// Initializes a new instance of the class. - /// - public TableCommandsTests(TableTestsFixture fixture) - { - _tableCommands = new TableCommands(); - _rangeCommands = new RangeCommands(); - _fixture = fixture; - _tableFile = fixture.TestFilePath; - _creationResult = fixture.CreationResult; - } - - #region Core Lifecycle Tests (7 tests) - - /// - /// Validates that the fixture creation succeeded. - /// LLM use case: "create a table from a range" - /// - [Fact] - public void Create_ViaFixture_CreatesTable() - { - Assert.True(_creationResult.Success, - $"Table creation failed during fixture initialization: {_creationResult.ErrorMessage}"); - Assert.True(_creationResult.FileCreated); - Assert.Equal(1, _creationResult.TablesCreated); - } - - /// - /// Tests listing tables in a workbook. - /// LLM use case: "show me all tables in this workbook" - /// - [Fact] - public void List_WithValidFile_ReturnsTables() - { - using var batch = ExcelSession.BeginBatch(_tableFile); - var result = _tableCommands.List(batch); - - Assert.True(result.Success, $"Expected success but got error: {result.ErrorMessage}"); - Assert.NotNull(result.Tables); - Assert.Contains(result.Tables, t => t.Name == "SalesTable"); - } - - /// - /// Tests getting table details. - /// LLM use case: "show me information about this table" - /// - [Fact] - public void Info_WithValidTable_ReturnsTableDetails() - { - using var batch = ExcelSession.BeginBatch(_tableFile); - var result = _tableCommands.Read(batch, "SalesTable"); - - Assert.True(result.Success); - Assert.NotNull(result.Table); - Assert.Equal("SalesTable", result.Table.Name); - Assert.Equal("Sales", result.Table.SheetName); - Assert.True(result.Table.HasHeaders); - Assert.Equal(4, result.Table.Columns?.Count); - } - - /// - /// Tests creating a new table. - /// LLM use case: "convert this range to a table" - /// - [Fact] - public void Create_WithValidData_CreatesTable() - { - var testFile = _fixture.CreateModificationTestFile(); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Add data to a new location (different from the SalesTable created by fixture method) - batch.Execute((ctx, ct) => - { - dynamic sheet = ctx.Book.Worksheets[1]; - sheet.Range["F1"].Value2 = "Name"; - sheet.Range["G1"].Value2 = "Value"; - sheet.Range["F2"].Value2 = "Test1"; - sheet.Range["G2"].Value2 = 100; - return 0; - }); - - // Create table - _tableCommands.Create(batch, "Sales", "TestTable", "F1:G2", true, "TableStyleLight1"); - // Create throws on error, so reaching here means success - - // Verify table was created - var listResult = _tableCommands.List(batch); - Assert.Contains(listResult.Tables, t => t.Name == "TestTable"); - } - - /// - /// Tests deleting a table. - /// LLM use case: "delete this table" - /// - [Fact] - public void Delete_WithExistingTable_RemovesTable() - { - var testFile = _fixture.CreateModificationTestFile(); - - using var batch = ExcelSession.BeginBatch(testFile); - _tableCommands.Delete(batch, "SalesTable"); - // Delete throws on error, so reaching here means success - - // Verify deletion - var listResult = _tableCommands.List(batch); - Assert.DoesNotContain(listResult.Tables, t => t.Name == "SalesTable"); - } - - /// - /// Tests renaming a table. - /// LLM use case: "rename this table" - /// - [Fact] - public void Rename_WithExistingTable_RenamesSuccessfully() - { - var testFile = _fixture.CreateModificationTestFile(); - - using var batch = ExcelSession.BeginBatch(testFile); - _tableCommands.Rename(batch, "SalesTable", "RevenueTable"); - // Rename throws on error, so reaching here means success - - // Verify rename - var listResult = _tableCommands.List(batch); - Assert.DoesNotContain(listResult.Tables, t => t.Name == "SalesTable"); - Assert.Contains(listResult.Tables, t => t.Name == "RevenueTable"); - } - - /// - /// Tests resizing a table. - /// LLM use case: "expand this table to include more rows" - /// - [Fact] - public void Resize_WithExistingTable_ResizesSuccessfully() - { - var testFile = _fixture.CreateModificationTestFile(); - - using var batch = ExcelSession.BeginBatch(testFile); - - var initialInfo = _tableCommands.Read(batch, "SalesTable"); - Assert.True(initialInfo.Success); - - _tableCommands.Resize(batch, "SalesTable", "A1:D10"); - // Resize throws on error, so reaching here means success - - // Verify resize - var resizedInfo = _tableCommands.Read(batch, "SalesTable"); - Assert.Equal(9, resizedInfo.Table!.RowCount); // 10 rows - 1 header - } - - #endregion - - #region Column Operations (2 tests) - - /// - /// Tests adding a column to a table. - /// LLM use case: "add a new column to this table" - /// - [Fact] - public void AddColumn_WithExistingTable_AddsColumnSuccessfully() - { - var testFile = _fixture.CreateModificationTestFile(); - - using var batch = ExcelSession.BeginBatch(testFile); - - var initialInfo = _tableCommands.Read(batch, "SalesTable"); - var initialColumnCount = initialInfo.Table!.Columns!.Count; - - _tableCommands.AddColumn(batch, "SalesTable", "NewColumn"); - // AddColumn throws on error, so reaching here means success - - // Verify column added - var updatedInfo = _tableCommands.Read(batch, "SalesTable"); - Assert.Equal(initialColumnCount + 1, updatedInfo.Table!.Columns!.Count); - Assert.Contains("NewColumn", updatedInfo.Table.Columns); - } - - /// - /// Tests renaming a column in a table. - /// LLM use case: "rename this table column" - /// - [Fact] - public void RenameColumn_WithExistingColumn_RenamesSuccessfully() - { - var testFile = _fixture.CreateModificationTestFile(); - - using var batch = ExcelSession.BeginBatch(testFile); - - _tableCommands.RenameColumn(batch, "SalesTable", "Amount", "Revenue"); - // RenameColumn throws on error, so reaching here means success - - // Verify rename - var info = _tableCommands.Read(batch, "SalesTable"); - Assert.Contains("Revenue", info.Table!.Columns!); - Assert.DoesNotContain("Amount", info.Table.Columns); - } - - #endregion - - #region Data Operations (2 tests) - - /// - /// Tests appending rows to a table. - /// LLM use case: "add these rows to the table" - /// - [Fact] - public void Append_WithNewData_AddsRowsToTable() - { - var testFile = _fixture.CreateModificationTestFile(); - - using var batch = ExcelSession.BeginBatch(testFile); - - var newRows = new List> - { - new() { "West", "Widget", 500, DateTime.Now }, - new() { "East", "Gadget", 600, DateTime.Now } - }; - - _tableCommands.Append(batch, "SalesTable", newRows); - // Append throws on error, so reaching here means success - - // Verify rows added - var info = _tableCommands.Read(batch, "SalesTable"); - Assert.True(info.Table!.RowCount >= 6); // Original 4 + appended 2 - } - - /// - /// Tests retrieving table data without filters. - /// LLM use case: "read the table data for analysis" - /// - [Fact] - public void GetData_WithoutFilters_ReturnsAllRows() - { - var testFile = _fixture.CreateModificationTestFile(); - - using var batch = ExcelSession.BeginBatch(testFile); - - var result = _tableCommands.GetData(batch, "SalesTable", visibleOnly: false); - - Assert.True(result.Success, result.ErrorMessage); - Assert.Equal("SalesTable", result.TableName); - Assert.Equal(4, result.Headers.Count); - Assert.Equal(4, result.RowCount); // Fixture data has 4 rows - Assert.Equal(result.RowCount, result.Data.Count); - } - - /// - /// Tests retrieving only visible table rows after applying a filter. - /// LLM use case: "get the filtered dataset" - /// - [Fact] - public void GetData_WithVisibleOnlyFilter_ReturnsFilteredRows() - { - var testFile = _fixture.CreateModificationTestFile(); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Apply filter so only North region remains visible - _tableCommands.ApplyFilterValues(batch, "SalesTable", "Region", ["North"]); - - var result = _tableCommands.GetData(batch, "SalesTable", visibleOnly: true); - - Assert.True(result.Success, result.ErrorMessage); - Assert.Equal(1, result.RowCount); - Assert.Single(result.Data); - Assert.Equal("North", result.Data[0][0]?.ToString()); - } - - /// - /// Tests getting structured reference for a table column. - /// LLM use case: "get the structured reference formula for this table column" - /// - [Fact] - public void GetStructuredReference_WithValidTable_ReturnsReference() - { - using var batch = ExcelSession.BeginBatch(_tableFile); - var result = _tableCommands.GetStructuredReference(batch, "SalesTable", TableRegion.Data, "Amount"); - - Assert.True(result.Success); - Assert.NotNull(result.StructuredReference); - Assert.Contains("SalesTable", result.StructuredReference); - Assert.Contains("Amount", result.StructuredReference); - } - - #endregion - - #region Filter Operations (2 tests) - - /// - /// Tests applying a filter to a table column. - /// LLM use case: "filter this table to show only these values" - /// - [Fact] - public void ApplyFilter_WithColumnCriteria_FiltersTable() - { - var testFile = _fixture.CreateModificationTestFile(); - - using var batch = ExcelSession.BeginBatch(testFile); - _tableCommands.ApplyFilterValues(batch, "SalesTable", "Region", ["North"]); - // ApplyFilter throws on error, so reaching here means success - } - - /// - /// Tests clearing all filters from a table. - /// LLM use case: "remove all filters from this table" - /// - [Fact] - public void ClearFilters_AfterFiltering_RemovesAllFilters() - { - var testFile = _fixture.CreateModificationTestFile(); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Apply filter first - _tableCommands.ApplyFilterValues(batch, "SalesTable", "Region", ["North"]); - - // Clear filters - _tableCommands.ClearFilters(batch, "SalesTable"); - // ClearFilters throws on error, so reaching here means success - } - - #endregion - - #region Totals Operations (2 tests) - - /// - /// Tests enabling totals row on a table. - /// LLM use case: "add a totals row to this table" - /// - [Fact] - public void ToggleTotals_EnableTotals_AddsTotalsRow() - { - var testFile = _fixture.CreateModificationTestFile(); - - using var batch = ExcelSession.BeginBatch(testFile); - _tableCommands.ToggleTotals(batch, "SalesTable", true); - // ToggleTotals throws on error, so reaching here means success - - // Verify totals enabled - var info = _tableCommands.Read(batch, "SalesTable"); - Assert.True(info.Table!.ShowTotals); - } - - /// - /// Tests setting a total function on a column. - /// LLM use case: "set the total for this column to sum" - /// - [Fact] - public void SetColumnTotal_WithSumFunction_SetsTotalFormula() - { - var testFile = _fixture.CreateModificationTestFile(); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Enable totals first - _tableCommands.ToggleTotals(batch, "SalesTable", true); - - // Set sum for Amount column - _tableCommands.SetColumnTotal(batch, "SalesTable", "Amount", "Sum"); - // SetColumnTotal throws on error, so reaching here means success - } - - #endregion - - #region Numeric Column Name Tests (3 tests) - - /// - /// Tests adding a column with a purely numeric name. - /// LLM use case: "add a column named 60 for 60 months data" - /// Regression test for: Column names can be numeric (e.g. 60 for 60 months) - /// - [Fact] - public void AddColumn_WithNumericName_AddsColumnSuccessfully() - { - var testFile = _fixture.CreateModificationTestFile(); - - using var batch = ExcelSession.BeginBatch(testFile); - - var initialInfo = _tableCommands.Read(batch, "SalesTable"); - var initialColumnCount = initialInfo.Table!.Columns!.Count; - - // Add column with purely numeric name - _tableCommands.AddColumn(batch, "SalesTable", "60"); - // AddColumn throws on error, so reaching here means success - - // Verify column added - var updatedInfo = _tableCommands.Read(batch, "SalesTable"); - Assert.Equal(initialColumnCount + 1, updatedInfo.Table!.Columns!.Count); - Assert.Contains("60", updatedInfo.Table.Columns); - } - - /// - /// Tests renaming a column to a purely numeric name. - /// LLM use case: "rename this column to 12 for 12 months" - /// Regression test for: Column names can be numeric (e.g. 60 for 60 months) - /// - [Fact] - public void RenameColumn_ToNumericName_RenamesSuccessfully() - { - var testFile = _fixture.CreateModificationTestFile(); - - using var batch = ExcelSession.BeginBatch(testFile); - - // Rename "Amount" column to numeric name "60" - _tableCommands.RenameColumn(batch, "SalesTable", "Amount", "60"); - // RenameColumn throws on error, so reaching here means success - - // Verify column renamed - var updatedInfo = _tableCommands.Read(batch, "SalesTable"); - Assert.Contains("60", updatedInfo.Table!.Columns!); - Assert.DoesNotContain("Amount", updatedInfo.Table.Columns); - } - - /// - /// Tests renaming a numeric column to another numeric name. - /// LLM use case: "rename column 60 to 120" - /// Regression test for: Column names can be numeric (e.g. 60 for 60 months) - /// - [Fact] - public void RenameColumn_NumericToNumeric_RenamesSuccessfully() - { - var testFile = _fixture.CreateModificationTestFile(); - - using var batch = ExcelSession.BeginBatch(testFile); - - // First add a numeric column - _tableCommands.AddColumn(batch, "SalesTable", "60"); - - // Then rename it to another numeric name - _tableCommands.RenameColumn(batch, "SalesTable", "60", "120"); - // RenameColumn throws on error, so reaching here means success - - // Verify column renamed - var updatedInfo = _tableCommands.Read(batch, "SalesTable"); - Assert.Contains("120", updatedInfo.Table!.Columns!); - Assert.DoesNotContain("60", updatedInfo.Table.Columns); - } - - #endregion -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Vba/VbaCommandsTests.Trust.ScriptCommands.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Vba/VbaCommandsTests.Trust.ScriptCommands.cs deleted file mode 100644 index aa2fdfc0..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Vba/VbaCommandsTests.Trust.ScriptCommands.cs +++ /dev/null @@ -1,152 +0,0 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Vba; - -/// -/// Tests for VBA operations when trust is enabled (CI environment has trust enabled) -/// -public partial class VbaCommandsTests -{ - [Fact] - public void ScriptCommands_List_WithTrustEnabled_WorksCorrectly() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - // Act - using var batch = ExcelSession.BeginBatch(testFile); - var result = _scriptCommands.List(batch); - - // Assert - Should succeed when VBA trust is enabled (as in CI environment) - Assert.True(result.Success, $"List should succeed with VBA trust enabled. Error: {result.ErrorMessage}"); - Assert.NotNull(result.Scripts); - } - [Fact] - public void ScriptCommands_Import_WithTrustEnabled_WorksCorrectly() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - string vbaCode = "Sub TestImport()\nEnd Sub"; - - // Act - using var batch = ExcelSession.BeginBatch(testFile); - _scriptCommands.Import(batch, "TestModule", vbaCode); - - // Assert - verify module exists via list - var listResult = _scriptCommands.List(batch); - Assert.Contains(listResult.Scripts, s => s.Name == "TestModule"); - } - [Fact] - public void ScriptCommands_Export_WithTrustEnabled_WorksCorrectly() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - // First import a module so we have something to export - string vbaCode = "Sub TestCode()\nEnd Sub"; - - using var batch = ExcelSession.BeginBatch(testFile); - _scriptCommands.Import(batch, "TestModule", vbaCode); - - // Act - View (export) the module we just imported - var result = _scriptCommands.View(batch, "TestModule"); - - // Assert - Should succeed when VBA trust is enabled (as in CI environment) - Assert.True(result.Success, $"View should succeed with VBA trust enabled. Error: {result.ErrorMessage}"); - Assert.NotNull(result.Code); - Assert.NotEmpty(result.Code); - } - [Fact] - public void ScriptCommands_Run_WithTrustEnabled_WorksCorrectly() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - // Import a test macro first - string vbaCode = @"Sub TestProcedure() - ' Simple test procedure -End Sub"; - - using var batch = ExcelSession.BeginBatch(testFile); - _scriptCommands.Import(batch, "TestModule", vbaCode); - - // Act - Run the macro - _scriptCommands.Run(batch, "TestModule.TestProcedure", null); - - // Assert - No exception thrown; to be thorough, ensure module still exists - var listResult = _scriptCommands.List(batch); - Assert.Contains(listResult.Scripts, s => s.Name == "TestModule"); - } - [Fact] - public void ScriptCommands_Delete_WithTrustEnabled_WorksCorrectly() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - // Import a module first - string vbaCode = "Sub TestCode()\nEnd Sub"; - - using var batch = ExcelSession.BeginBatch(testFile); - _scriptCommands.Import(batch, "TestModule", vbaCode); - - // Act - Delete the module - _scriptCommands.Delete(batch, "TestModule"); - - // Verify module is gone - var listResult = _scriptCommands.List(batch); - Assert.DoesNotContain(listResult.Scripts, s => s.Name == "TestModule"); - } - [Fact] - public void ScriptCommands_View_WithTrustEnabled_WorksCorrectly() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - // Import a module with known code - string expectedCode = "Sub ViewTest()\n MsgBox \"Hello\"\nEnd Sub"; - - using var batch = ExcelSession.BeginBatch(testFile); - _scriptCommands.Import(batch, "ViewTestModule", expectedCode); - - // Act - View the module code - var result = _scriptCommands.View(batch, "ViewTestModule"); - - // Assert - Should succeed and return the code - Assert.True(result.Success, $"View should succeed with VBA trust enabled. Error: {result.ErrorMessage}"); - Assert.NotNull(result.Code); - Assert.Contains("ViewTest", result.Code); - Assert.Contains("MsgBox", result.Code); - } - [Fact] - public void ScriptCommands_Update_WithTrustEnabled_WorksCorrectly() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - - // Import initial module - string initialCode = "Sub OriginalCode()\nEnd Sub"; - - using var batch = ExcelSession.BeginBatch(testFile); - _scriptCommands.Import(batch, "UpdateTestModule", initialCode); - - // Prepare updated code - string updatedCode = "Sub UpdatedCode()\n MsgBox \"Updated\"\nEnd Sub"; - - // Act - Update the module with new code - _scriptCommands.Update(batch, "UpdateTestModule", updatedCode); - - // Verify the code was updated - var viewResult = _scriptCommands.View(batch, "UpdateTestModule"); - Assert.True(viewResult.Success); - Assert.Contains("UpdatedCode", viewResult.Code); - Assert.Contains("Updated", viewResult.Code); - Assert.DoesNotContain("OriginalCode", viewResult.Code); - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Vba/VbaCommandsTests.Trust.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Vba/VbaCommandsTests.Trust.cs deleted file mode 100644 index 240bfae3..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Vba/VbaCommandsTests.Trust.cs +++ /dev/null @@ -1,36 +0,0 @@ -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Vba; - -using System.Runtime.Versioning; // Added for platform-specific registry access attribute - -/// -/// Integration tests for VBA Trust Detection functionality. -/// These tests validate VBA trust detection, guidance generation, and TestVbaTrustScope helper. -/// Each test uses a unique Excel file for complete test isolation. -/// -public partial class VbaCommandsTests -{ - - /// - /// Helper method to check VBA trust status via registry - /// - [SupportedOSPlatform("windows")] // Registry APIs are Windows-only; attribute silences CA1416 analyzer warnings - protected static bool IsVbaTrustEnabled() - { - try - { - using var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Office\16.0\Excel\Security"); - var value = key?.GetValue("AccessVBOM"); - return value != null && (int)value == 1; - } - catch (Exception) - { - // Test helper - registry access may fail - return false; - } - } -} - - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Vba/VbaCommandsTests.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Vba/VbaCommandsTests.cs deleted file mode 100644 index 288bb284..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Vba/VbaCommandsTests.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Sbroenne.ExcelMcp.Core.Commands; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Vba; - -/// -/// Integration tests for Script (VBA) Core operations. -/// These tests require Excel installation and VBA trust enabled. -/// Tests use Core commands directly (not through CLI wrapper). -/// Each test uses a unique Excel file for complete test isolation. -/// -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("RequiresExcel", "true")] -[Trait("Feature", "VBA")] -public partial class VbaCommandsTests : IClassFixture -{ - private readonly VbaCommands _scriptCommands; - private readonly VbaTestsFixture _fixture; - - /// - /// Initializes a new instance of the class. - /// - public VbaCommandsTests(VbaTestsFixture fixture) - { - _scriptCommands = new VbaCommands(); - _fixture = fixture; - } -} - - - - diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Window/WindowCommandsTests.Arrange.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Window/WindowCommandsTests.Arrange.cs deleted file mode 100644 index 792fe8a1..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Window/WindowCommandsTests.Arrange.cs +++ /dev/null @@ -1,118 +0,0 @@ -// -// Copyright (c) Stephan Brenner. All rights reserved. -// - -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Integration.Commands.Window; - -/// -/// Tests for Arrange preset operations. -/// -public partial class WindowCommandsTests -{ - [Theory] - [InlineData("left-half")] - [InlineData("right-half")] - [InlineData("top-half")] - [InlineData("bottom-half")] - [InlineData("center")] - [InlineData("full-screen")] - public void Arrange_ValidPresets_Succeed(string preset) - { - // Arrange - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - var result = _commands.Arrange(batch, preset); - - // Assert - Assert.True(result.Success, $"Arrange '{preset}' failed: {result.ErrorMessage}"); - Assert.Equal("arrange", result.Action); - Assert.Contains(preset, result.Message, StringComparison.OrdinalIgnoreCase); - - // Cleanup - _commands.Hide(batch); - } - - [Fact] - public void Arrange_InvalidPreset_Throws() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - // Act & Assert - Assert.ThrowsAny(() => _commands.Arrange(batch, "invalid-preset")); - } - - [Fact] - public void Arrange_LeftHalf_PositionsCorrectly() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - var result = _commands.Arrange(batch, "left-half"); - - // Assert - Assert.True(result.Success); - - var info = _commands.GetInfo(batch); - Assert.True(info.IsVisible); - Assert.Equal("normal", info.WindowState); - // Excel COM positioning can have small offsets (e.g., -1.5 instead of 0) - Assert.InRange(info.Left, -5, 5); - Assert.InRange(info.Top, -5, 5); - Assert.True(info.Width > 0); - Assert.True(info.Height > 0); - - // Cleanup - _commands.Hide(batch); - } - - [Fact] - public void Arrange_FullScreen_MaximizesWindow() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - var result = _commands.Arrange(batch, "full-screen"); - - // Assert - Assert.True(result.Success); - - var info = _commands.GetInfo(batch); - Assert.True(info.IsVisible); - Assert.Equal("maximized", info.WindowState); - - // Cleanup - _commands.Hide(batch); - } - - [Fact] - public void Arrange_WhenHidden_MakesVisible() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - _commands.Hide(batch); - - // Act - var result = _commands.Arrange(batch, "center"); - - // Assert - Assert.True(result.Success); - - var info = _commands.GetInfo(batch); - Assert.True(info.IsVisible, "Arrange should auto-show hidden Excel"); - - // Cleanup - _commands.Hide(batch); - } -} diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Window/WindowCommandsTests.Info.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Window/WindowCommandsTests.Info.cs deleted file mode 100644 index f5b17d1b..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Window/WindowCommandsTests.Info.cs +++ /dev/null @@ -1,74 +0,0 @@ -// -// Copyright (c) Stephan Brenner. All rights reserved. -// - -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Integration.Commands.Window; - -/// -/// Tests for GetInfo operation. -/// -public partial class WindowCommandsTests -{ - [Fact] - public void GetInfo_WhenHidden_ReturnsHiddenState() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - _commands.Hide(batch); - - // Act - var info = _commands.GetInfo(batch); - - // Assert - Assert.True(info.Success, $"GetInfo failed: {info.ErrorMessage}"); - Assert.Equal("get-info", info.Action); - Assert.False(info.IsVisible); - Assert.Contains("hidden", info.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void GetInfo_WhenVisible_ReturnsPositionAndSize() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - _commands.Show(batch); - - // Act - var info = _commands.GetInfo(batch); - - // Assert - Assert.True(info.Success); - Assert.True(info.IsVisible); - Assert.NotEmpty(info.WindowState); - Assert.True(info.Width > 0, "Width should be positive when visible"); - Assert.True(info.Height > 0, "Height should be positive when visible"); - - // Cleanup - _commands.Hide(batch); - } - - [Fact] - public void GetInfo_WhenMaximized_ReportsMaximizedState() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - _commands.Show(batch); - _commands.SetState(batch, "maximized"); - - // Act - var info = _commands.GetInfo(batch); - - // Assert - Assert.True(info.Success); - Assert.Equal("maximized", info.WindowState); - - // Cleanup - _commands.Hide(batch); - } -} diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Window/WindowCommandsTests.State.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Window/WindowCommandsTests.State.cs deleted file mode 100644 index a7b30138..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Window/WindowCommandsTests.State.cs +++ /dev/null @@ -1,118 +0,0 @@ -// -// Copyright (c) Stephan Brenner. All rights reserved. -// - -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Integration.Commands.Window; - -/// -/// Tests for SetState and SetPosition operations. -/// -public partial class WindowCommandsTests -{ - [Theory] - [InlineData("normal")] - [InlineData("maximized")] - [InlineData("minimized")] - public void SetState_ValidStates_Succeed(string state) - { - // Arrange - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - var result = _commands.SetState(batch, state); - - // Assert - Assert.True(result.Success, $"SetState '{state}' failed: {result.ErrorMessage}"); - Assert.Equal("set-state", result.Action); - Assert.Contains(state, result.Message, StringComparison.OrdinalIgnoreCase); - - // Cleanup - _commands.Hide(batch); - } - - [Fact] - public void SetState_InvalidState_Throws() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - // Act & Assert - Assert.ThrowsAny(() => _commands.SetState(batch, "invalid-state")); - } - - [Fact] - public void SetPosition_AllParameters_UpdatesPosition() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - var result = _commands.SetPosition(batch, left: 100, top: 50, width: 800, height: 600); - - // Assert - Assert.True(result.Success, $"SetPosition failed: {result.ErrorMessage}"); - Assert.Equal("set-position", result.Action); - - // Verify position via GetInfo - var info = _commands.GetInfo(batch); - Assert.True(info.IsVisible, "SetPosition should make Excel visible"); - - // Cleanup - _commands.Hide(batch); - } - - [Fact] - public void SetPosition_PartialParameters_OnlyUpdatesProvided() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - _commands.Show(batch); - _commands.SetState(batch, "normal"); - - // Get initial position - var beforeInfo = _commands.GetInfo(batch); - - // Act - only change left position - var result = _commands.SetPosition(batch, left: 200); - - // Assert - Assert.True(result.Success); - - var afterInfo = _commands.GetInfo(batch); - Assert.Equal(200, afterInfo.Left, 1.0); // Allow small floating-point tolerance - - // Cleanup - _commands.Hide(batch); - } - - [Fact] - public void SetState_MakesHiddenWindowVisible() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - _commands.Hide(batch); - - // Verify hidden - var beforeInfo = _commands.GetInfo(batch); - Assert.False(beforeInfo.IsVisible); - - // Act - var result = _commands.SetState(batch, "normal"); - - // Assert - should auto-show - Assert.True(result.Success); - var afterInfo = _commands.GetInfo(batch); - Assert.True(afterInfo.IsVisible); - - // Cleanup - _commands.Hide(batch); - } -} diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Window/WindowCommandsTests.StatusBar.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Window/WindowCommandsTests.StatusBar.cs deleted file mode 100644 index 3948f597..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Window/WindowCommandsTests.StatusBar.cs +++ /dev/null @@ -1,88 +0,0 @@ -// -// Copyright (c) Stephan Brenner. All rights reserved. -// - -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Integration.Commands.Window; - -/// -/// Tests for SetStatusBar and ClearStatusBar operations. -/// -public partial class WindowCommandsTests -{ - [Fact] - public void SetStatusBar_SetsText() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - var result = _commands.SetStatusBar(batch, "Building PivotTable..."); - - // Assert - Assert.True(result.Success, $"SetStatusBar failed: {result.ErrorMessage}"); - Assert.Equal("set-status-bar", result.Action); - Assert.Contains("Building PivotTable...", result.Message); - - // Cleanup - _commands.ClearStatusBar(batch); - } - - [Fact] - public void ClearStatusBar_RestoresDefault() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - _commands.SetStatusBar(batch, "Some progress text"); - - // Act - var result = _commands.ClearStatusBar(batch); - - // Assert - Assert.True(result.Success, $"ClearStatusBar failed: {result.ErrorMessage}"); - Assert.Equal("clear-status-bar", result.Action); - Assert.Contains("default", result.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void SetStatusBar_Then_Clear_Roundtrip() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - // Act & Assert - Set - var setResult = _commands.SetStatusBar(batch, "Step 1 of 3: Importing data..."); - Assert.True(setResult.Success); - - // Act & Assert - Update - var updateResult = _commands.SetStatusBar(batch, "Step 2 of 3: Creating chart..."); - Assert.True(updateResult.Success); - - // Act & Assert - Clear - var clearResult = _commands.ClearStatusBar(batch); - Assert.True(clearResult.Success); - } - - [Fact] - public void SetStatusBar_MultipleUpdates_AllSucceed() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - // Act - Simulate progress updates - for (int i = 1; i <= 5; i++) - { - var result = _commands.SetStatusBar(batch, $"Processing item {i} of 5..."); - Assert.True(result.Success, $"SetStatusBar call {i} failed: {result.ErrorMessage}"); - } - - // Cleanup - _commands.ClearStatusBar(batch); - } -} diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Window/WindowCommandsTests.Visibility.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Window/WindowCommandsTests.Visibility.cs deleted file mode 100644 index 0ef647da..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Window/WindowCommandsTests.Visibility.cs +++ /dev/null @@ -1,120 +0,0 @@ -// -// Copyright (c) Stephan Brenner. All rights reserved. -// - -using Sbroenne.ExcelMcp.ComInterop.Session; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Integration.Commands.Window; - -/// -/// Tests for Show, Hide, and BringToFront operations. -/// -public partial class WindowCommandsTests -{ - [Fact] - public void Show_MakesExcelVisible() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - // Start hidden - _commands.Hide(batch); - - // Act - var result = _commands.Show(batch); - - // Assert - Assert.True(result.Success, $"Show failed: {result.ErrorMessage}"); - Assert.Equal("show", result.Action); - - // Verify via GetInfo - var info = _commands.GetInfo(batch); - Assert.True(info.IsVisible); - - // Cleanup: hide again so tests don't leave visible Excel windows - _commands.Hide(batch); - } - - [Fact] - public void Hide_MakesExcelInvisible() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - // Ensure visible first - _commands.Show(batch); - - // Act - var result = _commands.Hide(batch); - - // Assert - Assert.True(result.Success, $"Hide failed: {result.ErrorMessage}"); - Assert.Equal("hide", result.Action); - - // Verify via GetInfo - var info = _commands.GetInfo(batch); - Assert.False(info.IsVisible); - } - - [Fact] - public void Show_Then_Hide_Roundtrip() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - - // Act & Assert - Show - var showResult = _commands.Show(batch); - Assert.True(showResult.Success); - - var infoAfterShow = _commands.GetInfo(batch); - Assert.True(infoAfterShow.IsVisible); - - // Act & Assert - Hide - var hideResult = _commands.Hide(batch); - Assert.True(hideResult.Success); - - var infoAfterHide = _commands.GetInfo(batch); - Assert.False(infoAfterHide.IsVisible); - } - - [Fact] - public void BringToFront_WhenHidden_ReturnsGuidanceMessage() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - _commands.Hide(batch); - - // Act - var result = _commands.BringToFront(batch); - - // Assert - should succeed but with guidance message - Assert.True(result.Success); - Assert.Equal("bring-to-front", result.Action); - Assert.Contains("show", result.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void BringToFront_WhenVisible_Succeeds() - { - // Arrange - var testFile = _fixture.CreateTestFile(); - using var batch = ExcelSession.BeginBatch(testFile); - _commands.Show(batch); - - // Act - var result = _commands.BringToFront(batch); - - // Assert - Assert.True(result.Success); - Assert.Equal("bring-to-front", result.Action); - Assert.Contains("foreground", result.Message, StringComparison.OrdinalIgnoreCase); - - // Cleanup - _commands.Hide(batch); - } -} diff --git a/tests/ExcelMcp.Core.Tests/Integration/Commands/Window/WindowCommandsTests.cs b/tests/ExcelMcp.Core.Tests/Integration/Commands/Window/WindowCommandsTests.cs deleted file mode 100644 index 924a4d5d..00000000 --- a/tests/ExcelMcp.Core.Tests/Integration/Commands/Window/WindowCommandsTests.cs +++ /dev/null @@ -1,29 +0,0 @@ -// -// Copyright (c) Stephan Brenner. All rights reserved. -// - -using Sbroenne.ExcelMcp.Core.Commands.Window; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; - -namespace Sbroenne.ExcelMcp.Core.Tests.Integration.Commands.Window; - -/// -/// Integration tests for Window management commands. -/// Tests visibility, window state, positioning, arrange presets, and status bar. -/// -[Trait("Layer", "Core")] -[Trait("Category", "Integration")] -[Trait("RequiresExcel", "true")] -[Trait("Feature", "Window")] -public partial class WindowCommandsTests : IClassFixture -{ - private readonly WindowCommands _commands; - private readonly WindowTestsFixture _fixture; - - public WindowCommandsTests(WindowTestsFixture fixture) - { - _commands = new WindowCommands(); - _fixture = fixture; - } -} diff --git a/tests/ExcelMcp.Diagnostics.Tests/Integration/Diagnostics/DataModelComApiBehaviorTests.cs b/tests/ExcelMcp.Diagnostics.Tests/Integration/Diagnostics/DataModelComApiBehaviorTests.cs deleted file mode 100644 index c2b32f91..00000000 --- a/tests/ExcelMcp.Diagnostics.Tests/Integration/Diagnostics/DataModelComApiBehaviorTests.cs +++ /dev/null @@ -1,1931 +0,0 @@ -// ============================================================================= -// DIAGNOSTIC TESTS - Direct Excel COM API Behavior for Data Model -// ============================================================================= -// Purpose: Understand what Excel COM API actually does for Data Model operations -// These tests document the REAL behavior of Excel's Data Model/Power Pivot COM API -// ============================================================================= - -// Suppress invalid-dynamic-call warnings - this diagnostic test file intentionally uses -// dynamic COM interop patterns to explore Excel's behavior. The Range[cell] pattern is -// standard COM interop for Excel and cannot be statically analyzed. -#pragma warning disable CS1061 // Member access on dynamic type - expected for COM interop exploration - -using System.Runtime.InteropServices; -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; -using Xunit.Abstractions; - -namespace Sbroenne.ExcelMcp.Diagnostics.Tests.Integration.Diagnostics; - -/// -/// Diagnostic tests for Data Model (Power Pivot) COM API behavior. -/// These tests use raw COM calls to understand Excel's actual behavior. -/// -[Trait("Category", "Integration")] -[Trait("Speed", "Slow")] -[Trait("Layer", "Diagnostics")] -[Trait("Feature", "DataModel")] -[Trait("RequiresExcel", "true")] -[Trait("RunType", "OnDemand")] -public class DataModelComApiBehaviorTests : IClassFixture, IDisposable -{ - private readonly string _tempDir; - private readonly ITestOutputHelper _output; - private dynamic? _excel; - private dynamic? _workbook; - private readonly string _testFile; - - // Simple M code for creating Data Model tables - private const string SalesQuery = """ - let - Source = #table( - {"Product", "Amount", "Quantity"}, - {{"Widget", 100, 5}, {"Gadget", 200, 3}, {"Gizmo", 150, 7}} - ) - in - Source - """; - - private const string ProductsQuery = """ - let - Source = #table( - {"ProductName", "Category", "Price"}, - {{"Widget", "Electronics", 20}, {"Gadget", "Electronics", 66.67}, {"Gizmo", "Tools", 21.43}} - ) - in - Source - """; - - public DataModelComApiBehaviorTests(TempDirectoryFixture fixture, ITestOutputHelper output) - { - _tempDir = fixture.TempDir; - _output = output; - _testFile = Path.Combine(_tempDir, $"DMDiag_{Guid.NewGuid():N}.xlsx"); - - // Create Excel instance directly via COM - var excelType = Type.GetTypeFromProgID("Excel.Application"); - _excel = Activator.CreateInstance(excelType!); - _excel.Visible = false; - _excel.DisplayAlerts = false; - - // Create new workbook - _workbook = _excel.Workbooks.Add(); - _workbook.SaveAs(_testFile); - - _output.WriteLine($"Test file: {_testFile}"); - } - - public void Dispose() - { - try - { - if (_workbook != null) - { - _workbook.Close(false); - ComUtilities.Release(ref _workbook); - } - if (_excel != null) - { - _excel.Quit(); - ComUtilities.Release(ref _excel); - } - } - catch (Exception ex) - { - _output.WriteLine($"Cleanup error: {ex.Message}"); - } - GC.SuppressFinalize(this); - GC.Collect(); - GC.WaitForPendingFinalizers(); - } - - // ========================================================================= - // SCENARIO 1: Access Data Model - // ========================================================================= - - [Fact] - public void Scenario1_AccessDataModel() - { - _output.WriteLine("=== SCENARIO 1: Access Data Model ==="); - - dynamic? model = null; - dynamic? modelTables = null; - dynamic? modelRelationships = null; - - try - { - model = _workbook.Model; - _output.WriteLine($"Model object obtained: {model != null}"); - - modelTables = model.ModelTables; - _output.WriteLine($"Initial ModelTables count: {modelTables.Count}"); - - modelRelationships = model.ModelRelationships; - _output.WriteLine($"Initial ModelRelationships count: {modelRelationships.Count}"); - - // Check model name/properties - try - { - string modelName = model.Name; - _output.WriteLine($"Model name: {modelName}"); - } - catch - { - _output.WriteLine("Model.Name not accessible"); - } - } - finally - { - ComUtilities.Release(ref modelRelationships); - ComUtilities.Release(ref modelTables); - ComUtilities.Release(ref model); - } - - _output.WriteLine("=== SCENARIO 1 COMPLETE ===\n"); - } - - // ========================================================================= - // SCENARIO 2: Create Table in Data Model (via Power Query) - // ========================================================================= - - [Fact] - public void Scenario2_CreateTableInDataModel() - { - _output.WriteLine("=== SCENARIO 2: Create Table in Data Model ==="); - - dynamic? queries = null; - dynamic? query = null; - dynamic? connections = null; - dynamic? model = null; - dynamic? modelTables = null; - - try - { - // First create a Power Query - queries = _workbook.Queries; - query = queries.Add("Sales", SalesQuery); - _output.WriteLine("Power Query 'Sales' created"); - - // Now load to Data Model using Add2 with CreateModelConnection=true - connections = _workbook.Connections; - - string connString = $"OLEDB;Provider=Microsoft.Mashup.OleDb.1;Data Source=$Workbook$;Location=Sales"; - - _output.WriteLine("\n--- Adding connection with CreateModelConnection=true ---"); - dynamic? conn = null; - try - { - conn = connections.Add2( - "Query - Sales", // Name - "Power Query - Sales", // Description - connString, // ConnectionString - "SELECT * FROM [Sales]", // CommandText - 2, // lCmdtype (xlCmdSql = 2) - true, // CreateModelConnection - LOAD TO DATA MODEL - false // ImportRelationships - ); - _output.WriteLine("Connection added with CreateModelConnection=true"); - - // Refresh to load data into model - conn.Refresh(); - _output.WriteLine("Connection refreshed"); - - ComUtilities.Release(ref conn); - } - catch (COMException ex) - { - _output.WriteLine($"Add2 failed: 0x{ex.HResult:X8} - {ex.Message}"); - ComUtilities.Release(ref conn); - } - - // Check if table appeared in Data Model - model = _workbook.Model; - modelTables = model.ModelTables; - _output.WriteLine($"\nModelTables count after load: {modelTables.Count}"); - - for (int i = 1; i <= modelTables.Count; i++) - { - dynamic? table = modelTables[i]; - _output.WriteLine($" Table {i}: {table.Name}"); - - // List columns - dynamic? columns = table.ModelTableColumns; - _output.WriteLine($" Columns: {columns.Count}"); - for (int j = 1; j <= columns.Count; j++) - { - dynamic? col = columns[j]; - _output.WriteLine($" - {col.Name} ({col.DataType})"); - ComUtilities.Release(ref col); - } - ComUtilities.Release(ref columns); - ComUtilities.Release(ref table); - } - } - finally - { - ComUtilities.Release(ref modelTables); - ComUtilities.Release(ref model); - ComUtilities.Release(ref connections); - ComUtilities.Release(ref query); - ComUtilities.Release(ref queries); - } - - _output.WriteLine("=== SCENARIO 2 COMPLETE ===\n"); - } - - // ========================================================================= - // SCENARIO 3: Add DAX Measure - // ========================================================================= - - [Fact] - public void Scenario3_AddDaxMeasure() - { - _output.WriteLine("=== SCENARIO 3: Add DAX Measure ==="); - - dynamic? model = null; - dynamic? modelTables = null; - dynamic? measures = null; - - try - { - // First ensure we have a table in the model - CreateDataModelTable("Sales", SalesQuery); - - model = _workbook.Model; - modelTables = model.ModelTables; - - if (modelTables.Count == 0) - { - _output.WriteLine("No tables in Data Model. Skipping measure test."); - return; - } - - dynamic? table = modelTables[1]; - string tableName = table.Name; - _output.WriteLine($"Adding measure to table: {tableName}"); - - // Get measures collection - measures = model.ModelMeasures; - int measureCountBefore = measures.Count; - _output.WriteLine($"Measures before add: {measureCountBefore}"); - - // Add a measure - _output.WriteLine("\n--- Adding DAX measure ---"); - dynamic? formatInfo = null; - try - { - // ModelMeasures.Add signature: (MeasureName, AssociatedTable, Formula, FormatInformation, [Description]) - // FormatInformation is REQUIRED - get from Model.ModelFormatGeneral property - formatInfo = model.ModelFormatGeneral; - dynamic? measure = measures.Add( - "TotalAmount", // MeasureName - table, // AssociatedTable - "SUM(Query[Amount])", // Formula (use table name "Query" from M code) - formatInfo, // FormatInformation (REQUIRED) - "Total of all amounts" // Description (optional) - ); - - _output.WriteLine($"Measure added: {measure.Name}"); - _output.WriteLine($"Formula: {measure.Formula}"); - - ComUtilities.Release(ref measure); - } - catch (COMException ex) - { - _output.WriteLine($"Add measure failed: 0x{ex.HResult:X8}"); - _output.WriteLine($"Message: {ex.Message}"); - } - finally - { - ComUtilities.Release(ref formatInfo); - } - - // Verify - ComUtilities.Release(ref measures); - measures = model.ModelMeasures; - _output.WriteLine($"\nMeasures after add: {measures.Count}"); - - ComUtilities.Release(ref table); - } - finally - { - ComUtilities.Release(ref measures); - ComUtilities.Release(ref modelTables); - ComUtilities.Release(ref model); - } - - _output.WriteLine("=== SCENARIO 3 COMPLETE ===\n"); - } - - // ========================================================================= - // SCENARIO 4: Update DAX Measure - // ========================================================================= - - [Fact] - public void Scenario4_UpdateDaxMeasure() - { - _output.WriteLine("=== SCENARIO 4: Update DAX Measure ==="); - - dynamic? model = null; - dynamic? measures = null; - - try - { - // Setup: Create table and measure - CreateDataModelTable("Sales", SalesQuery); - CreateMeasure("TotalAmount", "SUM(Sales[Amount])"); - - model = _workbook.Model; - measures = model.ModelMeasures; - - if (measures.Count == 0) - { - _output.WriteLine("No measures found. Skipping update test."); - return; - } - - // Find and update the measure - dynamic? measure = null; - for (int i = 1; i <= measures.Count; i++) - { - dynamic? m = measures[i]; - if (m.Name == "TotalAmount") - { - measure = m; - break; - } - ComUtilities.Release(ref m); - } - - if (measure == null) - { - _output.WriteLine("Measure 'TotalAmount' not found"); - return; - } - - _output.WriteLine($"Original formula: {measure.Formula}"); - - // Update formula - _output.WriteLine("\n--- Updating measure formula ---"); - try - { - measure.Formula = "SUM(Sales[Amount]) * 1.1"; - _output.WriteLine($"New formula: {measure.Formula}"); - } - catch (COMException ex) - { - _output.WriteLine($"Update failed: 0x{ex.HResult:X8} - {ex.Message}"); - } - - // Update description - try - { - measure.Description = "Updated: Total with 10% markup"; - _output.WriteLine($"New description: {measure.Description}"); - } - catch (COMException ex) - { - _output.WriteLine($"Description update failed: {ex.Message}"); - } - - ComUtilities.Release(ref measure); - } - finally - { - ComUtilities.Release(ref measures); - ComUtilities.Release(ref model); - } - - _output.WriteLine("=== SCENARIO 4 COMPLETE ===\n"); - } - - // ========================================================================= - // SCENARIO 5: Delete DAX Measure - // ========================================================================= - - [Fact] - public void Scenario5_DeleteDaxMeasure() - { - _output.WriteLine("=== SCENARIO 5: Delete DAX Measure ==="); - - dynamic? model = null; - dynamic? measures = null; - - try - { - // Setup - CreateDataModelTable("Sales", SalesQuery); - CreateMeasure("ToDelete", "SUM(Sales[Amount])"); - - model = _workbook.Model; - measures = model.ModelMeasures; - - int countBefore = measures.Count; - _output.WriteLine($"Measures before delete: {countBefore}"); - - // Find and delete the measure - for (int i = 1; i <= measures.Count; i++) - { - dynamic? m = measures[i]; - if (m.Name == "ToDelete") - { - _output.WriteLine("\n--- Deleting measure 'ToDelete' ---"); - m.Delete(); - _output.WriteLine("Measure deleted"); - ComUtilities.Release(ref m); - break; - } - ComUtilities.Release(ref m); - } - - // Verify - ComUtilities.Release(ref measures); - measures = model.ModelMeasures; - _output.WriteLine($"Measures after delete: {measures.Count}"); - - Assert.Equal(countBefore - 1, (int)measures.Count); - } - finally - { - ComUtilities.Release(ref measures); - ComUtilities.Release(ref model); - } - - _output.WriteLine("=== SCENARIO 5 COMPLETE ===\n"); - } - - // ========================================================================= - // SCENARIO 6: Create Relationship Between Tables - // ========================================================================= - - [Fact] - public void Scenario6_CreateRelationship() - { - _output.WriteLine("=== SCENARIO 6: Create Relationship ==="); - - dynamic? model = null; - dynamic? modelTables = null; - dynamic? relationships = null; - - try - { - // Create two tables with related columns - CreateDataModelTable("Sales", SalesQuery); - CreateDataModelTable("Products", ProductsQuery); - - model = _workbook.Model; - modelTables = model.ModelTables; - relationships = model.ModelRelationships; - - _output.WriteLine($"Tables in model: {modelTables.Count}"); - _output.WriteLine($"Relationships before: {relationships.Count}"); - - if (modelTables.Count < 2) - { - _output.WriteLine("Need at least 2 tables for relationship test"); - return; - } - - // Find the tables and columns - dynamic? salesTable = null; - dynamic? productsTable = null; - dynamic? salesProductCol = null; - dynamic? productsNameCol = null; - - for (int i = 1; i <= modelTables.Count; i++) - { - dynamic? t = modelTables[i]; - string name = t.Name; - if (name == "Sales") - salesTable = t; - else if (name == "Products") - productsTable = t; - else - ComUtilities.Release(ref t); - } - - if (salesTable != null && productsTable != null) - { - // Get the columns - dynamic? salesCols = salesTable.ModelTableColumns; - for (int i = 1; i <= salesCols.Count; i++) - { - dynamic? col = salesCols[i]; - if (col.Name == "Product") - { - salesProductCol = col; - break; - } - ComUtilities.Release(ref col); - } - ComUtilities.Release(ref salesCols); - - dynamic? prodCols = productsTable.ModelTableColumns; - for (int i = 1; i <= prodCols.Count; i++) - { - dynamic? col = prodCols[i]; - if (col.Name == "ProductName") - { - productsNameCol = col; - break; - } - ComUtilities.Release(ref col); - } - ComUtilities.Release(ref prodCols); - - if (salesProductCol != null && productsNameCol != null) - { - _output.WriteLine("\n--- Creating relationship ---"); - try - { - dynamic? rel = relationships.Add( - salesProductCol, // ForeignKeyColumn (many side) - productsNameCol // PrimaryKeyColumn (one side) - ); - - _output.WriteLine($"Relationship created"); - _output.WriteLine($" From: {rel.ForeignKeyColumn.Name} in {rel.ForeignKeyTable.Name}"); - _output.WriteLine($" To: {rel.PrimaryKeyColumn.Name} in {rel.PrimaryKeyTable.Name}"); - _output.WriteLine($" Active: {rel.Active}"); - - ComUtilities.Release(ref rel); - } - catch (COMException ex) - { - _output.WriteLine($"Create relationship failed: 0x{ex.HResult:X8}"); - _output.WriteLine($"Message: {ex.Message}"); - } - } - else - { - _output.WriteLine("Could not find matching columns for relationship"); - } - - ComUtilities.Release(ref salesProductCol); - ComUtilities.Release(ref productsNameCol); - } - - // Verify - ComUtilities.Release(ref relationships); - relationships = model.ModelRelationships; - _output.WriteLine($"\nRelationships after: {relationships.Count}"); - - ComUtilities.Release(ref salesTable); - ComUtilities.Release(ref productsTable); - } - finally - { - ComUtilities.Release(ref relationships); - ComUtilities.Release(ref modelTables); - ComUtilities.Release(ref model); - } - - _output.WriteLine("=== SCENARIO 6 COMPLETE ===\n"); - } - - // ========================================================================= - // SCENARIO 7: Delete Relationship - // ========================================================================= - - [Fact] - public void Scenario7_DeleteRelationship() - { - _output.WriteLine("=== SCENARIO 7: Delete Relationship ==="); - - dynamic? model = null; - dynamic? relationships = null; - - try - { - // Setup: Create tables and relationship - CreateDataModelTable("Sales", SalesQuery); - CreateDataModelTable("Products", ProductsQuery); - CreateRelationshipBetweenTables(); - - model = _workbook.Model; - relationships = model.ModelRelationships; - - int countBefore = relationships.Count; - _output.WriteLine($"Relationships before delete: {countBefore}"); - - if (countBefore == 0) - { - _output.WriteLine("No relationships to delete"); - return; - } - - // Delete first relationship - dynamic? rel = relationships[1]; - _output.WriteLine($"\n--- Deleting relationship ---"); - rel.Delete(); - _output.WriteLine("Relationship deleted"); - ComUtilities.Release(ref rel); - - // Verify - ComUtilities.Release(ref relationships); - relationships = model.ModelRelationships; - _output.WriteLine($"Relationships after delete: {relationships.Count}"); - } - finally - { - ComUtilities.Release(ref relationships); - ComUtilities.Release(ref model); - } - - _output.WriteLine("=== SCENARIO 7 COMPLETE ===\n"); - } - - // ========================================================================= - // SCENARIO 8: Delete Query That Loaded to Data Model - // ========================================================================= - - [Fact] - public void Scenario8_DeleteQueryWithDataModelTable() - { - _output.WriteLine("=== SCENARIO 8: Delete Query That Loaded to Data Model ==="); - - dynamic? queries = null; - dynamic? model = null; - dynamic? modelTables = null; - - try - { - // Create query and load to data model - CreateDataModelTable("OrphanTest", SalesQuery); - - queries = _workbook.Queries; - model = _workbook.Model; - modelTables = model.ModelTables; - - int queryCountBefore = queries.Count; - int tableCountBefore = modelTables.Count; - - _output.WriteLine($"Queries before delete: {queryCountBefore}"); - _output.WriteLine($"Model tables before delete: {tableCountBefore}"); - - // Find and delete the query - dynamic? query = null; - for (int i = 1; i <= queries.Count; i++) - { - dynamic? q = queries[i]; - if (q.Name == "OrphanTest") - { - query = q; - break; - } - ComUtilities.Release(ref q); - } - - if (query != null) - { - _output.WriteLine("\n--- Deleting query 'OrphanTest' ---"); - query.Delete(); - _output.WriteLine("Query deleted"); - ComUtilities.Release(ref query); - } - - // KEY QUESTION: What happens to the Data Model table? - ComUtilities.Release(ref modelTables); - modelTables = model.ModelTables; - - _output.WriteLine($"\nQueries after delete: {queries.Count}"); - _output.WriteLine($"Model tables after delete: {modelTables.Count}"); - - if (modelTables.Count == tableCountBefore) - { - _output.WriteLine("DATA MODEL TABLE SURVIVES! Query deletion does NOT remove model table."); - - // List remaining tables - for (int i = 1; i <= modelTables.Count; i++) - { - dynamic? t = modelTables[i]; - _output.WriteLine($" Orphaned table: {t.Name}"); - ComUtilities.Release(ref t); - } - } - else - { - _output.WriteLine("DATA MODEL TABLE REMOVED! Query deletion removes model table too."); - } - } - finally - { - ComUtilities.Release(ref modelTables); - ComUtilities.Release(ref model); - ComUtilities.Release(ref queries); - } - - _output.WriteLine("=== SCENARIO 8 COMPLETE ===\n"); - } - - // ========================================================================= - // SCENARIO 9: Measure Referencing Deleted Table - // ========================================================================= - - [Fact] - public void Scenario9_MeasureWithDeletedTable() - { - _output.WriteLine("=== SCENARIO 9: Measure Referencing Deleted Table ==="); - - dynamic? queries = null; - dynamic? model = null; - dynamic? measures = null; - - try - { - // Create table and measure - CreateDataModelTable("ToDelete", SalesQuery); - CreateMeasure("OrphanedMeasure", "SUM(ToDelete[Amount])"); - - model = _workbook.Model; - measures = model.ModelMeasures; - queries = _workbook.Queries; - - _output.WriteLine($"Measures before table delete: {measures.Count}"); - - // Delete the query (which may or may not delete the model table) - _output.WriteLine("\n--- Deleting source query ---"); - for (int i = 1; i <= queries.Count; i++) - { - dynamic? q = queries[i]; - if (q.Name == "ToDelete") - { - q.Delete(); - _output.WriteLine("Query deleted"); - ComUtilities.Release(ref q); - break; - } - ComUtilities.Release(ref q); - } - - // Check measures - ComUtilities.Release(ref measures); - measures = model.ModelMeasures; - _output.WriteLine($"Measures after table delete: {measures.Count}"); - - // Try to access the measure - for (int i = 1; i <= measures.Count; i++) - { - dynamic? m = measures[i]; - try - { - _output.WriteLine($"Measure: {m.Name}"); - _output.WriteLine($" Formula: {m.Formula}"); - _output.WriteLine($" Table: {m.AssociatedTable?.Name ?? "(null)"}"); - } - catch (COMException ex) - { - _output.WriteLine($" ERROR accessing measure: {ex.Message}"); - } - ComUtilities.Release(ref m); - } - } - finally - { - ComUtilities.Release(ref measures); - ComUtilities.Release(ref model); - ComUtilities.Release(ref queries); - } - - _output.WriteLine("=== SCENARIO 9 COMPLETE ===\n"); - } - - // ========================================================================= - // SCENARIO 10: Multiple Measures on Same Table - // ========================================================================= - - [Fact] - public void Scenario10_MultipleMeasures() - { - _output.WriteLine("=== SCENARIO 10: Multiple Measures on Same Table ==="); - - dynamic? model = null; - dynamic? measures = null; - - try - { - CreateDataModelTable("Sales", SalesQuery); - - CreateMeasure("TotalAmount", "SUM(Sales[Amount])"); - CreateMeasure("TotalQty", "SUM(Sales[Quantity])"); - CreateMeasure("AvgAmount", "AVERAGE(Sales[Amount])"); - CreateMeasure("CountRows", "COUNTROWS(Sales)"); - - model = _workbook.Model; - measures = model.ModelMeasures; - - _output.WriteLine($"Total measures created: {measures.Count}"); - - for (int i = 1; i <= measures.Count; i++) - { - dynamic? m = measures[i]; - _output.WriteLine($" {m.Name}: {m.Formula}"); - ComUtilities.Release(ref m); - } - } - finally - { - ComUtilities.Release(ref measures); - ComUtilities.Release(ref model); - } - - _output.WriteLine("=== SCENARIO 10 COMPLETE ===\n"); - } - - // ========================================================================= - // SCENARIO 11: Model Refresh - // ========================================================================= - - [Fact] - public void Scenario11_ModelRefresh() - { - _output.WriteLine("=== SCENARIO 11: Model Refresh ==="); - - dynamic? model = null; - - try - { - CreateDataModelTable("Sales", SalesQuery); - - model = _workbook.Model; - - _output.WriteLine("--- Attempting model.Refresh() ---"); - try - { - model.Refresh(); - _output.WriteLine("model.Refresh() succeeded"); - } - catch (COMException ex) - { - _output.WriteLine($"model.Refresh() failed: 0x{ex.HResult:X8}"); - _output.WriteLine($"Message: {ex.Message}"); - } - - // Alternative: Refresh via connection - _output.WriteLine("\n--- Attempting connection refresh ---"); - dynamic? connections = _workbook.Connections; - for (int i = 1; i <= connections.Count; i++) - { - dynamic? conn = connections[i]; - try - { - conn.Refresh(); - _output.WriteLine($"Connection '{conn.Name}' refreshed"); - } - catch (COMException ex) - { - _output.WriteLine($"Connection '{conn.Name}' refresh failed: {ex.Message}"); - } - ComUtilities.Release(ref conn); - } - ComUtilities.Release(ref connections); - } - finally - { - ComUtilities.Release(ref model); - } - - _output.WriteLine("=== SCENARIO 11 COMPLETE ===\n"); - } - - // ========================================================================= - // SCENARIO 12: CUBEVALUE Recalculation After Data Model Refresh - // Issue #313: CUBEVALUE formulas return error codes after refresh - // ========================================================================= - - [Fact] - public void Scenario12_CubeValueRecalculationAfterRefresh() - { - _output.WriteLine("=== SCENARIO 12: CUBEVALUE Recalculation After Refresh ==="); - _output.WriteLine("This test verifies if CUBEVALUE formulas automatically recalculate after Data Model refresh."); - - dynamic? model = null; - dynamic? sheet = null; - dynamic? range = null; - - try - { - // Step 1: Create Data Model with a table - _output.WriteLine("\n--- Step 1: Create Data Model table ---"); - CreateDataModelTable("Sales", SalesQuery); - - // Step 2: Create a DAX measure - _output.WriteLine("\n--- Step 2: Create DAX measure ---"); - CreateMeasure("TotalAmount", "SUM(Sales[Amount])"); - - // Step 3: Check current calculation mode - _output.WriteLine("\n--- Step 3: Check calculation mode ---"); - int calcMode = _excel.Calculation; - _output.WriteLine($"Current calculation mode: {calcMode} (xlCalculationAutomatic=-4105, xlCalculationManual=-4135)"); - - // Step 4: Add CUBEVALUE formula to worksheet - _output.WriteLine("\n--- Step 4: Add CUBEVALUE formula ---"); - sheet = _workbook.Worksheets[1]; - - // First, let's check what cube connections exist - _output.WriteLine("Checking available cube connections..."); - try - { - dynamic? connections = _workbook.Connections; - for (int i = 1; i <= connections.Count; i++) - { - dynamic? conn = connections[i]; - _output.WriteLine($" Connection {i}: '{conn.Name}' Type={conn.Type}"); - ComUtilities.Release(ref conn); - } - ComUtilities.Release(ref connections); - } - catch (COMException ex) - { - _output.WriteLine($" Error listing connections: {ex.Message}"); - } - - // Also check the Model's connection - try - { - _output.WriteLine($"Model.Name: {model?.Name ?? "(model is null)"}"); - } -#pragma warning disable CA1031 // Intentional: diagnostic test logs all exceptions - catch (Exception ex) -#pragma warning restore CA1031 - { - _output.WriteLine($" Error getting model name: {ex.Message}"); - } - - // Try different CUBEVALUE connection names - // CUBEVALUE format: =CUBEVALUE(connection, member_expression) - // The connection should be the Data Model connection name - string cubeFormula = "=CUBEVALUE(\"ThisWorkbookDataModel\",\"[Measures].[TotalAmount]\")"; - range = sheet.Range["A1"]; - range.Formula = cubeFormula; - _output.WriteLine($"Set formula in A1: {cubeFormula}"); - - // Also try the alternative connection format in A2 - dynamic? rangeA2 = sheet.Range["A2"]; - string cubeFormula2 = "=CUBEVALUE(\"Query - Sales\",\"[Measures].[TotalAmount]\")"; - rangeA2.Formula = cubeFormula2; - _output.WriteLine($"Set formula in A2: {cubeFormula2}"); - ComUtilities.Release(ref rangeA2); - - // Try different measure formats in A3-A5 - dynamic? rangeA3 = sheet.Range["A3"]; - rangeA3.Formula = "=CUBEVALUE(\"ThisWorkbookDataModel\",\"TotalAmount\")"; - _output.WriteLine($"Set formula in A3: =CUBEVALUE(\"ThisWorkbookDataModel\",\"TotalAmount\")"); - ComUtilities.Release(ref rangeA3); - - dynamic? rangeA4 = sheet.Range["A4"]; - rangeA4.Formula = "=CUBEVALUE(\"ThisWorkbookDataModel\",\"[Sales].[TotalAmount]\")"; - _output.WriteLine($"Set formula in A4: =CUBEVALUE(\"ThisWorkbookDataModel\",\"[Sales].[TotalAmount]\")"); - ComUtilities.Release(ref rangeA4); - - // Try a CUBEMEMBER first approach - dynamic? rangeA5 = sheet.Range["A5"]; - rangeA5.Formula = "=CUBEMEMBER(\"ThisWorkbookDataModel\",\"[Measures].[TotalAmount]\")"; - _output.WriteLine($"Set formula in A5: =CUBEMEMBER(\"ThisWorkbookDataModel\",\"[Measures].[TotalAmount]\")"); - ComUtilities.Release(ref rangeA5); - - // Step 5: Read value BEFORE refresh - _output.WriteLine("\n--- Step 5: Read A1 value BEFORE any explicit recalculation ---"); - object valueBefore = range.Value2; - _output.WriteLine($"A1 Value (raw): {valueBefore}"); - _output.WriteLine($"A1 Value type: {valueBefore?.GetType().Name ?? "null"}"); - - // Check for error codes - if (valueBefore is int or double) - { - double numVal = Convert.ToDouble(valueBefore, System.Globalization.CultureInfo.InvariantCulture); - if (numVal < 0) - { - _output.WriteLine($"⚠️ NEGATIVE VALUE - likely Excel error code!"); - DescribeExcelErrorCode(numVal); - } - else - { - _output.WriteLine($"✅ Numeric value: {numVal} (expected ~450 from SUM of 100+200+150)"); - } - } - - // Step 6: Refresh Data Model - _output.WriteLine("\n--- Step 6: Refresh Data Model ---"); - model = _workbook.Model; - try - { - model.Refresh(); - _output.WriteLine("model.Refresh() succeeded"); - } - catch (COMException ex) - { - _output.WriteLine($"model.Refresh() failed: 0x{ex.HResult:X8} - {ex.Message}"); - // Try connection refresh as fallback - _output.WriteLine("Attempting connection refresh instead..."); - RefreshAllConnections(); - } - - // Step 7: Read value AFTER refresh (without explicit Calculate) - _output.WriteLine("\n--- Step 7: Read A1 value AFTER refresh (no explicit Calculate) ---"); - object valueAfterRefresh = range.Value2; - _output.WriteLine($"A1 Value (raw): {valueAfterRefresh}"); - _output.WriteLine($"A1 Value type: {valueAfterRefresh?.GetType().Name ?? "null"}"); - - if (valueAfterRefresh is int or double) - { - double numVal = Convert.ToDouble(valueAfterRefresh, System.Globalization.CultureInfo.InvariantCulture); - if (numVal < 0) - { - _output.WriteLine($"⚠️ STILL ERROR CODE after refresh!"); - DescribeExcelErrorCode(numVal); - } - else - { - _output.WriteLine($"✅ Numeric value: {numVal}"); - } - } - - // Step 8: Call Application.Calculate and re-read - _output.WriteLine("\n--- Step 8: Call Application.Calculate() ---"); - _excel.Calculate(); - _output.WriteLine("Application.Calculate() called"); - - object valueAfterCalculate = range.Value2; - _output.WriteLine($"A1 Value (raw): {valueAfterCalculate}"); - _output.WriteLine($"A1 Value type: {valueAfterCalculate?.GetType().Name ?? "null"}"); - - if (valueAfterCalculate is int or double) - { - double numVal = Convert.ToDouble(valueAfterCalculate, System.Globalization.CultureInfo.InvariantCulture); - if (numVal < 0) - { - _output.WriteLine($"⚠️ STILL ERROR CODE after Calculate!"); - DescribeExcelErrorCode(numVal); - } - else - { - _output.WriteLine($"✅ Numeric value: {numVal}"); - } - } - - // Step 9: Call Application.CalculateFull and re-read - _output.WriteLine("\n--- Step 9: Call Application.CalculateFull() ---"); - _excel.CalculateFull(); - _output.WriteLine("Application.CalculateFull() called"); - - object valueAfterFullCalc = range.Value2; - _output.WriteLine($"A1 Value (raw): {valueAfterFullCalc}"); - _output.WriteLine($"A1 Value type: {valueAfterFullCalc?.GetType().Name ?? "null"}"); - - if (valueAfterFullCalc is int or double) - { - double numVal = Convert.ToDouble(valueAfterFullCalc, System.Globalization.CultureInfo.InvariantCulture); - if (numVal < 0) - { - _output.WriteLine($"⚠️ STILL ERROR CODE after CalculateFull!"); - DescribeExcelErrorCode(numVal); - } - else - { - _output.WriteLine($"✅ Numeric value: {numVal} (expected ~450)"); - } - } - - // Step 10: Also check A2 (Query - Sales connection) - _output.WriteLine("\n--- Step 10: Check A2-A5 (different formula formats) ---"); - dynamic? rangeA2Check = sheet.Range["A2"]; - dynamic? rangeA3Check = sheet.Range["A3"]; - dynamic? rangeA4Check = sheet.Range["A4"]; - dynamic? rangeA5Check = sheet.Range["A5"]; - - object a2Value = rangeA2Check.Value2; - object a3Value = rangeA3Check.Value2; - object a4Value = rangeA4Check.Value2; - object a5Value = rangeA5Check.Value2; - - _output.WriteLine($"A2 (Query - Sales + [Measures].[TotalAmount]): {FormatValue(a2Value)}"); - _output.WriteLine($"A3 (ThisWorkbookDataModel + TotalAmount): {FormatValue(a3Value)}"); - _output.WriteLine($"A4 (ThisWorkbookDataModel + [Sales].[TotalAmount]): {FormatValue(a4Value)}"); - _output.WriteLine($"A5 (CUBEMEMBER [Measures].[TotalAmount]): {FormatValue(a5Value)}"); - - ComUtilities.Release(ref rangeA2Check); - ComUtilities.Release(ref rangeA3Check); - ComUtilities.Release(ref rangeA4Check); - ComUtilities.Release(ref rangeA5Check); - - // Step 11: Try CalculateFullRebuild (Ctrl+Alt+Shift+F9) - _output.WriteLine("\n--- Step 11: Call Application.CalculateFullRebuild() ---"); - try - { - _excel.CalculateFullRebuild(); - _output.WriteLine("Application.CalculateFullRebuild() called"); - - object valueAfterRebuild = range.Value2; - _output.WriteLine($"A1 Value (raw): {valueAfterRebuild}"); - if (valueAfterRebuild is int or double) - { - double numVal = Convert.ToDouble(valueAfterRebuild, System.Globalization.CultureInfo.InvariantCulture); - if (numVal < 0) - { - _output.WriteLine($"⚠️ STILL ERROR CODE after CalculateFullRebuild!"); - DescribeExcelErrorCode(numVal); - } - else - { - _output.WriteLine($"✅ Numeric value: {numVal}"); - } - } - } - catch (COMException ex) - { - _output.WriteLine($"CalculateFullRebuild failed: {ex.Message}"); - } - - // Step 12: Save workbook - // NOTE: The "close and reopen" part was never implemented, so we just save - _output.WriteLine("\n--- Step 12: Save workbook ---"); - _workbook.Save(); - _output.WriteLine("Workbook saved"); - - // Check value after save - object valueAfterSave = range.Value2; - _output.WriteLine($"A1 Value after save: {valueAfterSave}"); - - // Summary - _output.WriteLine("\n--- Summary ---"); - _output.WriteLine($"Before refresh: {valueBefore}"); - _output.WriteLine($"After refresh: {valueAfterRefresh}"); - _output.WriteLine($"After Calculate(): {valueAfterCalculate}"); - _output.WriteLine($"After CalculateFull():{valueAfterFullCalc}"); - _output.WriteLine($"After Save: {valueAfterSave}"); - _output.WriteLine(""); - _output.WriteLine("CONCLUSION: If all values are negative error codes, CUBEVALUE"); - _output.WriteLine("formulas may require the workbook to be opened in visible Excel"); - _output.WriteLine("with Data Model initialized before formulas can calculate."); - - // Step 13: Try with Excel Visible - _output.WriteLine("\n--- Step 13: Set Excel.Visible = true and recalculate ---"); - _excel.Visible = true; - Thread.Sleep(2000); // Give Excel time to render - _excel.CalculateFullRebuild(); - Thread.Sleep(1000); - object valueAfterVisible = range.Value2; - _output.WriteLine($"A1 Value with Excel Visible: {valueAfterVisible}"); - if (valueAfterVisible is int or double) - { - double numVal = Convert.ToDouble(valueAfterVisible, System.Globalization.CultureInfo.InvariantCulture); - if (numVal < 0) - { - _output.WriteLine($"⚠️ STILL ERROR CODE even with Excel visible!"); - DescribeExcelErrorCode(numVal); - } - else - { - _output.WriteLine($"✅ SUCCESS! Value: {numVal} - Visible Excel fixed it!"); - } - } - _excel.Visible = false; // Hide again for cleanup - } - catch (COMException ex) - { - _output.WriteLine($"Test failed with COM exception: 0x{ex.HResult:X8}"); - _output.WriteLine($"Message: {ex.Message}"); - } - finally - { - ComUtilities.Release(ref range); - ComUtilities.Release(ref sheet); - ComUtilities.Release(ref model); - } - - _output.WriteLine("=== SCENARIO 12 COMPLETE ===\n"); - } - - private static string FormatValue(object? value) - { - if (value == null) return "null"; - if (value is int or double) - { - double numVal = Convert.ToDouble(value, System.Globalization.CultureInfo.InvariantCulture); - if (numVal < 0) - { - int code = Convert.ToInt32(numVal); - string errName = code switch - { - -2146826288 => "#NULL!", - -2146826281 => "#DIV/0!", - -2146826246 => "#VALUE!", - -2146826259 => "#REF!", - -2146826252 => "#NAME?", - -2146826265 => "#NUM!", - -2146826245 => "#N/A", - _ => $"Error {code}" - }; - return $"{code} ({errName})"; - } - return $"{numVal} ✅"; - } - return value.ToString() ?? "empty"; - } - - private void DescribeExcelErrorCode(double errorCode) - { - int code = Convert.ToInt32(errorCode); - string description = code switch - { - -2146826288 => "#NULL! - Incorrect range operator or missing intersection", - -2146826281 => "#DIV/0! - Division by zero", - -2146826246 => "#VALUE! - Wrong argument type", - -2146826259 => "#REF! - Invalid cell reference", - -2146826252 => "#NAME? - Unrecognized formula name", - -2146826265 => "#NUM! - Invalid numeric value", - -2146826245 => "#N/A - Value not found, CUBE member not found, or Data Model not refreshed/calculated", - _ => $"Unknown error code: {code}" - }; - _output.WriteLine($" Error code {code} = {description}"); - } - - private void RefreshAllConnections() - { - dynamic? connections = null; - try - { - connections = _workbook.Connections; - for (int i = 1; i <= connections.Count; i++) - { - dynamic? conn = connections[i]; - try - { - conn.Refresh(); - _output.WriteLine($" Refreshed connection: {conn.Name}"); - } - catch (COMException ex) - { - _output.WriteLine($" Failed to refresh {conn.Name}: {ex.Message}"); - } - finally - { - ComUtilities.Release(ref conn); - } - } - } - finally - { - ComUtilities.Release(ref connections); - } - } - - // ========================================================================= - // Helper Methods - // ========================================================================= - - private void CreateDataModelTable(string queryName, string mCode) - { - dynamic? queries = null; - dynamic? query = null; - dynamic? connections = null; - dynamic? conn = null; - - try - { - queries = _workbook.Queries; - - // Check if query already exists - for (int i = 1; i <= queries.Count; i++) - { - dynamic? q = queries[i]; - if (q.Name == queryName) - { - ComUtilities.Release(ref q); - _output.WriteLine($"Query '{queryName}' already exists"); - return; - } - ComUtilities.Release(ref q); - } - - query = queries.Add(queryName, mCode); - - connections = _workbook.Connections; - string connString = $"OLEDB;Provider=Microsoft.Mashup.OleDb.1;Data Source=$Workbook$;Location={queryName}"; - - conn = connections.Add2( - $"Query - {queryName}", - $"Power Query - {queryName}", - connString, - $"SELECT * FROM [{queryName}]", - 2, // xlCmdSql - true, // CreateModelConnection - false // ImportRelationships - ); - conn.Refresh(); - _output.WriteLine($"Created and loaded '{queryName}' to Data Model"); - } - catch (COMException ex) - { - _output.WriteLine($"CreateDataModelTable failed: {ex.Message}"); - } - finally - { - ComUtilities.Release(ref conn); - ComUtilities.Release(ref connections); - ComUtilities.Release(ref query); - ComUtilities.Release(ref queries); - } - } - - private void CreateMeasure(string name, string formula) - { - dynamic? model = null; - dynamic? modelTables = null; - dynamic? measures = null; - dynamic? table = null; - dynamic? formatInfo = null; - - try - { - model = _workbook.Model; - modelTables = model.ModelTables; - - if (modelTables.Count == 0) - { - _output.WriteLine($"Cannot create measure '{name}': No tables in model"); - return; - } - - table = modelTables[1]; - measures = model.ModelMeasures; - - // ModelMeasures.Add signature: (MeasureName, AssociatedTable, Formula, FormatInformation, [Description]) - // FormatInformation is REQUIRED - get from Model.ModelFormatGeneral property - formatInfo = model.ModelFormatGeneral; - dynamic? measure = measures.Add(name, table, formula, formatInfo); - _output.WriteLine($"Created measure '{name}'"); - ComUtilities.Release(ref measure); - } - catch (COMException ex) - { - _output.WriteLine($"CreateMeasure failed: {ex.Message}"); - } - finally - { - ComUtilities.Release(ref formatInfo); - ComUtilities.Release(ref table); - ComUtilities.Release(ref measures); - ComUtilities.Release(ref modelTables); - ComUtilities.Release(ref model); - } - } - - private void CreateRelationshipBetweenTables() - { - dynamic? model = null; - dynamic? modelTables = null; - dynamic? relationships = null; - - try - { - model = _workbook.Model; - modelTables = model.ModelTables; - relationships = model.ModelRelationships; - - dynamic? salesTable = null; - dynamic? productsTable = null; - - for (int i = 1; i <= modelTables.Count; i++) - { - dynamic? t = modelTables[i]; - string name = t.Name; - if (name == "Sales") - salesTable = t; - else if (name == "Products") - productsTable = t; - else - ComUtilities.Release(ref t); - } - - if (salesTable == null || productsTable == null) - { - _output.WriteLine("Cannot create relationship: Missing tables"); - ComUtilities.Release(ref salesTable); - ComUtilities.Release(ref productsTable); - return; - } - - // Find columns - dynamic? salesProductCol = null; - dynamic? productsNameCol = null; - - dynamic? salesCols = salesTable.ModelTableColumns; - for (int i = 1; i <= salesCols.Count; i++) - { - dynamic? col = salesCols[i]; - if (col.Name == "Product") - { - salesProductCol = col; - break; - } - ComUtilities.Release(ref col); - } - ComUtilities.Release(ref salesCols); - - dynamic? prodCols = productsTable.ModelTableColumns; - for (int i = 1; i <= prodCols.Count; i++) - { - dynamic? col = prodCols[i]; - if (col.Name == "ProductName") - { - productsNameCol = col; - break; - } - ComUtilities.Release(ref col); - } - ComUtilities.Release(ref prodCols); - - if (salesProductCol != null && productsNameCol != null) - { - dynamic? rel = relationships.Add(salesProductCol, productsNameCol); - _output.WriteLine("Created relationship Sales[Product] -> Products[ProductName]"); - ComUtilities.Release(ref rel); - } - - ComUtilities.Release(ref salesProductCol); - ComUtilities.Release(ref productsNameCol); - ComUtilities.Release(ref salesTable); - ComUtilities.Release(ref productsTable); - } - catch (COMException ex) - { - _output.WriteLine($"CreateRelationship failed: {ex.Message}"); - } - finally - { - ComUtilities.Release(ref relationships); - ComUtilities.Release(ref modelTables); - ComUtilities.Release(ref model); - } - } - - // ========================================================================= - // SCENARIO 13: CUBEVALUE with fresh Excel instance (Hidden vs Visible) - // Runs CUBEVALUE test with a completely fresh Excel instance - // ========================================================================= - - [Theory] - [InlineData(false, "Hidden")] - [InlineData(true, "Visible")] - public void Scenario13_CubeValueFreshExcelInstance(bool excelVisible, string visibilityLabel) - { - _output.WriteLine($"=== SCENARIO 13: CUBEVALUE Fresh Excel Instance ({visibilityLabel}) ==="); - _output.WriteLine($"Excel.Visible = {excelVisible}"); - _output.WriteLine(""); - - dynamic? excel = null; - dynamic? workbook = null; - dynamic? model = null; - dynamic? sheet = null; - dynamic? range = null; - string testFile = Path.Combine(_tempDir, $"CubeValue_{visibilityLabel}_{Guid.NewGuid():N}.xlsx"); - - try - { - // Step 1: Create fresh Excel instance - _output.WriteLine("--- Step 1: Create fresh Excel instance ---"); - var excelType = Type.GetTypeFromProgID("Excel.Application"); - excel = Activator.CreateInstance(excelType!); - excel.Visible = excelVisible; - excel.DisplayAlerts = false; - _output.WriteLine($"Excel instance created. Visible={excel.Visible}"); - - if (excelVisible) - { - // Give Excel time to fully initialize when visible - Thread.Sleep(2000); - } - - // Step 2: Create workbook - _output.WriteLine("\n--- Step 2: Create workbook ---"); - workbook = excel.Workbooks.Add(); - workbook.SaveAs(testFile); - _output.WriteLine($"Workbook created: {testFile}"); - - // Step 3: Create Power Query and load to Data Model - _output.WriteLine("\n--- Step 3: Create Power Query with Data Model load ---"); - string queryName = "Sales"; - string mCode = """ - let - Source = #table( - {"Product", "Amount", "Quantity"}, - {{"Widget", 100, 5}, {"Gadget", 200, 3}, {"Gizmo", 150, 7}} - ) - in - Source - """; - - dynamic? queries = workbook.Queries; - dynamic? query = queries.Add(queryName, mCode); - _output.WriteLine($"Query '{queryName}' created"); - - // Create connection and load to Data Model - string connectionString = $"OLEDB;Provider=Microsoft.Mashup.OleDb.1;Data Source=$Workbook$;Location={queryName}"; - string commandText = $"SELECT * FROM [{queryName}]"; - - dynamic? connections = workbook.Connections; - dynamic? conn = connections.Add2( - $"Query - {queryName}", - $"Power Query - {queryName}", - connectionString, - commandText, - 2, // xlCmdSql - true, // CreateModelConnection - LOAD TO DATA MODEL - false // ImportRelationships - ); - _output.WriteLine("Connection created with CreateModelConnection=true"); - - // Refresh to load data - conn.Refresh(); - _output.WriteLine("Connection refreshed"); - ComUtilities.Release(ref conn); - ComUtilities.Release(ref connections); - ComUtilities.Release(ref query); - ComUtilities.Release(ref queries); - - if (excelVisible) - { - Thread.Sleep(1000); - } - - // Step 4: Create DAX measure - _output.WriteLine("\n--- Step 4: Create DAX measure ---"); - model = workbook.Model; - dynamic? modelTables = model.ModelTables; - _output.WriteLine($"ModelTables.Count: {modelTables.Count}"); - dynamic? targetTable = null; - string targetTableName = ""; - - for (int i = 1; i <= modelTables.Count; i++) - { - dynamic? t = modelTables[i]; - string tName = t.Name?.ToString() ?? "(null)"; - _output.WriteLine($" Table[{i}]: {tName}"); - if (targetTable == null) - { - targetTable = t; - targetTableName = tName; - } - else - { - ComUtilities.Release(ref t); - } - } - ComUtilities.Release(ref modelTables); - - if (targetTable != null) - { - dynamic? measures = model.ModelMeasures; - dynamic? formatInfo = model.ModelFormatGeneral; // REQUIRED - try - { - // Use the actual table name in the DAX formula - string daxFormula = $"SUM({targetTableName}[Amount])"; - dynamic? measure = measures.Add( - "TotalAmount", - targetTable, - daxFormula, - formatInfo - ); - _output.WriteLine($"Created measure 'TotalAmount' = {daxFormula}"); - ComUtilities.Release(ref measure); - } - catch (COMException ex) - { - _output.WriteLine($"Failed to create measure: 0x{ex.HResult:X} - {ex.Message}"); - } - finally - { - ComUtilities.Release(ref formatInfo); - ComUtilities.Release(ref measures); - } - } - else - { - _output.WriteLine("ERROR: No tables found in model!"); - } - ComUtilities.Release(ref targetTable); - - // Step 5: Discover Data Model connection name - _output.WriteLine("\n--- Step 5: Discover Data Model connection name ---"); - dynamic? dataModelConn = null; - string dataModelConnName = "ThisWorkbookDataModel"; - try - { - dataModelConn = model.DataModelConnection; - dataModelConnName = dataModelConn?.Name?.ToString() ?? "ThisWorkbookDataModel"; - _output.WriteLine($"Model.DataModelConnection.Name: {dataModelConnName}"); - } -#pragma warning disable CA1031 // Intentional: diagnostic test logs all exceptions - catch (Exception ex) -#pragma warning restore CA1031 - { - _output.WriteLine($"Could not get DataModelConnection: {ex.Message}"); - } - finally - { - ComUtilities.Release(ref dataModelConn); - } - - // Also list all connections - _output.WriteLine("All connections in workbook:"); - dynamic? conns = workbook.Connections; - for (int i = 1; i <= conns.Count; i++) - { - dynamic? c = conns[i]; - string cName = c?.Name?.ToString() ?? "(null)"; - int cType = Convert.ToInt32(c?.Type ?? 0); - bool inModel = false; - try { inModel = c.InModel; } catch (COMException) { /* InModel property may not exist on all connection types */ } - _output.WriteLine($" [{i}] Name: '{cName}', Type: {cType}, InModel: {inModel}"); - ComUtilities.Release(ref c); - } - ComUtilities.Release(ref conns); - - // Step 5b: Try to create a proper Model Workbook Connection (per MS docs) - _output.WriteLine("\n--- Step 5b: Try Model.CreateModelWorkbookConnection ---"); - dynamic? modelWorkbookConn = null; - try - { - // Get the first table from the model to use as parameter - dynamic? modelTables2 = model.ModelTables; - dynamic? firstTable = modelTables2[1]; - modelWorkbookConn = model.CreateModelWorkbookConnection(firstTable); - string connName = modelWorkbookConn?.Name?.ToString() ?? "(null)"; - _output.WriteLine($"Created Model Workbook Connection: '{connName}'"); - ComUtilities.Release(ref firstTable); - ComUtilities.Release(ref modelTables2); - } - catch (COMException ex) - { - _output.WriteLine($"CreateModelWorkbookConnection failed: 0x{ex.HResult:X8} - {ex.Message}"); - } - finally - { - ComUtilities.Release(ref modelWorkbookConn); - } - - // List connections again after attempting to create model connection - _output.WriteLine("Connections after CreateModelWorkbookConnection:"); - conns = workbook.Connections; - for (int i = 1; i <= conns.Count; i++) - { - dynamic? c = conns[i]; - string cName = c?.Name?.ToString() ?? "(null)"; - int cType = Convert.ToInt32(c?.Type ?? 0); - _output.WriteLine($" [{i}] Name: '{cName}', Type: {cType}"); - ComUtilities.Release(ref c); - } - ComUtilities.Release(ref conns); - - // Step 6: Add CUBEVALUE formula - _output.WriteLine("\n--- Step 6: Add CUBEVALUE formulas (testing different syntax) ---"); - sheet = workbook.Worksheets[1]; - - // Build formulas with discovered names - // Important: DAX measures are referenced as [Measures].[MeasureName] - // According to MS docs, the special Data Model connection is named "Workbook Data Model" (with spaces) - string[] formulas = new[] - { - $"=CUBEVALUE(\"{dataModelConnName}\",\"[Measures].[TotalAmount]\")", - $"=CUBEVALUE(\"{dataModelConnName}\",\"{targetTableName}[TotalAmount]\")", - "=CUBEVALUE(\"Workbook Data Model\",\"[Measures].[TotalAmount]\")", - "=CUBEVALUE(\"ThisWorkbookDataModel\",\"[Measures].[TotalAmount]\")", - "=CUBEVALUE(\"Query - Sales\",\"[Measures].[TotalAmount]\")", - $"=CUBEVALUE(\"{dataModelConnName}\",\"[{targetTableName}].[Measures].[TotalAmount]\")", - }; - - string[] formulaDescriptions = new[] - { - $"DataModelConnection.Name ({dataModelConnName})", - "TableName[MeasureName] pattern", - "Workbook Data Model (MS docs name)", - "ThisWorkbookDataModel (hardcoded)", - "Query - Sales connection", - "[TableName].[Measures].[MeasureName] pattern", - }; - - for (int i = 0; i < formulas.Length; i++) - { - string cell = $"A{i + 1}"; - range = sheet.Range[cell]; - range.Formula = formulas[i]; - _output.WriteLine($"{cell}: {formulaDescriptions[i]}"); - _output.WriteLine($" Formula: {formulas[i]}"); - ComUtilities.Release(ref range); - } - - // Also test CUBEMEMBER to see if that works - _output.WriteLine("\n--- Also testing CUBEMEMBER function ---"); - dynamic? memberRange = sheet.Range["B1"]; - string cubeMemberFormula = $"=CUBEMEMBER(\"{dataModelConnName}\",\"[Measures].[TotalAmount]\")"; - memberRange.Formula = cubeMemberFormula; - _output.WriteLine($"B1: CUBEMEMBER formula: {cubeMemberFormula}"); - ComUtilities.Release(ref memberRange); - - range = sheet.Range["A1"]; // Reset for value reading - - // Step 6b: Read values immediately - _output.WriteLine("\n--- Step 6b: Read values immediately ---"); - for (int i = 0; i < formulas.Length; i++) - { - string cell = $"A{i + 1}"; - dynamic? r = sheet.Range[cell]; - object val = r.Value2; - _output.WriteLine($"{cell}: {FormatValue(val)} - {formulaDescriptions[i]}"); - ComUtilities.Release(ref r); - } - - // Check CUBEMEMBER - dynamic? memberRangeCheck = sheet.Range["B1"]; - object memberVal = memberRangeCheck.Value2; - _output.WriteLine($"B1 (CUBEMEMBER): {FormatValue(memberVal)}"); - ComUtilities.Release(ref memberRangeCheck); - - // Step 7: Refresh Data Model - _output.WriteLine("\n--- Step 7: Refresh Data Model ---"); - try - { - model.Refresh(); - _output.WriteLine("model.Refresh() succeeded"); - } - catch (COMException ex) - { - _output.WriteLine($"model.Refresh() failed: 0x{ex.HResult:X8}"); - } - - if (excelVisible) - { - Thread.Sleep(1000); - } - - _output.WriteLine("Values after Data Model refresh:"); - for (int i = 0; i < formulas.Length; i++) - { - string cell = $"A{i + 1}"; - dynamic? r = sheet.Range[cell]; - object val = r.Value2; - _output.WriteLine($" {cell}: {FormatValue(val)}"); - ComUtilities.Release(ref r); - } - - // Step 8: Calculate - _output.WriteLine("\n--- Step 8: Application.Calculate() ---"); - try - { - excel.Calculate(); - _output.WriteLine("Calculate() succeeded"); - } - catch (COMException ex) - { - _output.WriteLine($"Calculate() failed: 0x{ex.HResult:X8} - {ex.Message}"); - } - - if (excelVisible) - { - Thread.Sleep(500); - } - - _output.WriteLine("Values after Calculate:"); - for (int i = 0; i < formulas.Length; i++) - { - string cell = $"A{i + 1}"; - dynamic? r = sheet.Range[cell]; - object val = r.Value2; - _output.WriteLine($" {cell}: {FormatValue(val)}"); - ComUtilities.Release(ref r); - } - - // Step 9: CalculateFull - _output.WriteLine("\n--- Step 9: Application.CalculateFull() ---"); - try - { - excel.CalculateFull(); - _output.WriteLine("CalculateFull() succeeded"); - } - catch (COMException ex) - { - _output.WriteLine($"CalculateFull() failed: 0x{ex.HResult:X8} - {ex.Message}"); - } - - if (excelVisible) - { - Thread.Sleep(500); - } - - _output.WriteLine("Values after CalculateFull:"); - for (int i = 0; i < formulas.Length; i++) - { - string cell = $"A{i + 1}"; - dynamic? r = sheet.Range[cell]; - object val = r.Value2; - _output.WriteLine($" {cell}: {FormatValue(val)}"); - ComUtilities.Release(ref r); - } - - // Step 10: CalculateFullRebuild - _output.WriteLine("\n--- Step 10: Application.CalculateFullRebuild() ---"); - try - { - excel.CalculateFullRebuild(); - _output.WriteLine("CalculateFullRebuild() succeeded"); - } - catch (COMException ex) - { - _output.WriteLine($"CalculateFullRebuild() failed: 0x{ex.HResult:X8} - {ex.Message}"); - } - - if (excelVisible) - { - Thread.Sleep(500); - } - - _output.WriteLine("Values after CalculateFullRebuild:"); - object? bestValue = null; - for (int i = 0; i < formulas.Length; i++) - { - string cell = $"A{i + 1}"; - dynamic? r = sheet.Range[cell]; - object val = r.Value2; - _output.WriteLine($" {cell}: {FormatValue(val)} - {formulaDescriptions[i]}"); - if (val is int or double && bestValue == null) - { - bestValue = val; - } - ComUtilities.Release(ref r); - } - - // Also check CUBEMEMBER - dynamic? memberRangeFinal = sheet.Range["B1"]; - object memberValFinal = memberRangeFinal.Value2; - _output.WriteLine($" B1 (CUBEMEMBER): {FormatValue(memberValFinal)}"); - ComUtilities.Release(ref memberRangeFinal); - - // Summary - _output.WriteLine("\n=== SUMMARY ==="); - _output.WriteLine($"Excel.Visible: {excelVisible}"); - _output.WriteLine($"Data Model Connection: {dataModelConnName}"); - _output.WriteLine($"Target Table: {targetTableName}"); - _output.WriteLine($"Best numeric result: {(bestValue != null ? bestValue.ToString() : "None - all formulas returned errors")}"); - - // Determine success - bool success = bestValue != null; - _output.WriteLine(success ? "\n✅ At least one CUBEVALUE formula returned a numeric value" : "\n❌ ALL CUBEVALUE formulas still show errors"); - } - catch (COMException ex) - { - _output.WriteLine($"\n❌ Test failed with COM exception: 0x{ex.HResult:X8}"); - _output.WriteLine($"Message: {ex.Message}"); - } - finally - { - _output.WriteLine("\n--- Cleanup ---"); - ComUtilities.Release(ref range); - ComUtilities.Release(ref sheet); - ComUtilities.Release(ref model); - - if (workbook != null) - { - try - { - workbook.Close(false); - } -#pragma warning disable CA1031 // Intentional: cleanup code must not throw - catch (Exception) { /* Ignore cleanup errors */ } -#pragma warning restore CA1031 - ComUtilities.Release(ref workbook); - } - - if (excel != null) - { - try - { - excel.Quit(); - } -#pragma warning disable CA1031 // Intentional: cleanup code must not throw - catch (Exception) { /* Ignore cleanup errors */ } -#pragma warning restore CA1031 - ComUtilities.Release(ref excel); - } - - // Clean up test file - try - { - if (File.Exists(testFile)) - File.Delete(testFile); - } -#pragma warning disable CA1031 // Intentional: cleanup code must not throw - catch (Exception) { /* Ignore file cleanup errors */ } -#pragma warning restore CA1031 - } - - _output.WriteLine($"=== SCENARIO 13 ({visibilityLabel}) COMPLETE ===\n"); - } -} - - - - diff --git a/tests/ExcelMcp.Diagnostics.Tests/Integration/Diagnostics/PivotTableRefreshBehaviorTests.cs b/tests/ExcelMcp.Diagnostics.Tests/Integration/Diagnostics/PivotTableRefreshBehaviorTests.cs deleted file mode 100644 index 548aa092..00000000 --- a/tests/ExcelMcp.Diagnostics.Tests/Integration/Diagnostics/PivotTableRefreshBehaviorTests.cs +++ /dev/null @@ -1,529 +0,0 @@ -// Copyright (c) Sbroenne. All rights reserved. -// Licensed under the MIT License. - -using System.Runtime.InteropServices; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; -using Xunit.Abstractions; - -namespace Sbroenne.ExcelMcp.Diagnostics.Tests.Diagnostics; - -/// -/// Diagnostic tests to understand Excel's native behavior regarding RefreshTable(). -/// These tests use RAW Excel COM API without our abstraction layer to determine: -/// 1. Which operations require RefreshTable() to take effect -/// 2. Which operations work immediately without RefreshTable() -/// -/// Purpose: Inform optimization decisions by understanding Excel's true behavior. -/// -[Trait("Category", "Integration")] -[Trait("Layer", "Diagnostics")] -[Trait("Speed", "Slow")] -[Trait("RequiresExcel", "true")] -[Trait("RunType", "OnDemand")] -public class PivotTableRefreshBehaviorTests : IClassFixture, IDisposable -{ - private readonly string _tempDir; - private readonly ITestOutputHelper _output; - private dynamic? _excel; - private dynamic? _workbook; - private readonly string _testFile; - - // Excel constants - private const int xlRowField = 1; - private const int xlColumnField = 2; - private const int xlDataField = 4; - private const int xlPageField = 3; - private const int xlHidden = 0; - private const int xlSum = -4157; - private const int xlCount = -4112; - private const int xlAverage = -4106; - private const int xlDatabase = 1; - - public PivotTableRefreshBehaviorTests(TempDirectoryFixture fixture, ITestOutputHelper output) - { - _tempDir = fixture.TempDir; - _output = output; - _testFile = Path.Combine(_tempDir, $"RefreshBehavior_{Guid.NewGuid():N}.xlsx"); - - // Create Excel instance directly (no abstraction) - var excelType = Type.GetTypeFromProgID("Excel.Application"); - _excel = Activator.CreateInstance(excelType!); - _excel.Visible = false; - _excel.DisplayAlerts = false; - - // Create workbook with test data - _workbook = _excel.Workbooks.Add(); - SetupTestData(); - } - - private void SetupTestData() - { - dynamic sheet = _workbook.Worksheets[1]; - sheet.Name = "Data"; - - // Create simple sales data - sheet.Range["A1"].Value2 = "Region"; - sheet.Range["B1"].Value2 = "Product"; - sheet.Range["C1"].Value2 = "Sales"; - sheet.Range["D1"].Value2 = "Quantity"; - - sheet.Range["A2"].Value2 = "North"; - sheet.Range["B2"].Value2 = "Widget"; - sheet.Range["C2"].Value2 = 100; - sheet.Range["D2"].Value2 = 10; - - sheet.Range["A3"].Value2 = "South"; - sheet.Range["B3"].Value2 = "Gadget"; - sheet.Range["C3"].Value2 = 200; - sheet.Range["D3"].Value2 = 20; - - sheet.Range["A4"].Value2 = "North"; - sheet.Range["B4"].Value2 = "Gadget"; - sheet.Range["C4"].Value2 = 150; - sheet.Range["D4"].Value2 = 15; - - sheet.Range["A5"].Value2 = "South"; - sheet.Range["B5"].Value2 = "Widget"; - sheet.Range["C5"].Value2 = 175; - sheet.Range["D5"].Value2 = 17; - } - - private dynamic CreatePivotTable(string name) - { - dynamic dataSheet = _workbook.Worksheets["Data"]; - dynamic sourceRange = dataSheet.Range["A1:D5"]; - - // Add a new sheet for the PivotTable - dynamic pivotSheet = _workbook.Worksheets.Add(); - pivotSheet.Name = $"Pivot_{name}"; - - // Create PivotCache and PivotTable - dynamic pivotCache = _workbook.PivotCaches().Create(xlDatabase, sourceRange); - dynamic pivotTable = pivotCache.CreatePivotTable(pivotSheet.Range["A3"], name); - - return pivotTable; - } - - [Fact] - public void AddRowField_WithoutRefresh_VerifyOrientationTakesEffect() - { - // Arrange - var pivot = CreatePivotTable("TestPivot1"); - dynamic field = pivot.PivotFields("Region"); - - // Act - Set orientation WITHOUT calling RefreshTable() - field.Orientation = xlRowField; - // NO pivot.RefreshTable(); - - // Assert - Check if orientation took effect - int actualOrientation = Convert.ToInt32(field.Orientation); - _output.WriteLine($"AddRowField WITHOUT RefreshTable:"); - _output.WriteLine($" Expected Orientation: {xlRowField} (xlRowField)"); - _output.WriteLine($" Actual Orientation: {actualOrientation}"); - _output.WriteLine($" Match: {actualOrientation == xlRowField}"); - - // Check if data appears in the PivotTable - dynamic pivotSheet = _workbook.Worksheets.Item($"Pivot_TestPivot1"); - string cellA4Value = pivotSheet.Range["A4"].Value2?.ToString() ?? "(empty)"; - _output.WriteLine($" Cell A4 value: {cellA4Value}"); - - Assert.Equal(xlRowField, actualOrientation); - } - - [Fact] - public void AddRowField_WithRefresh_VerifyOrientationTakesEffect() - { - // Arrange - var pivot = CreatePivotTable("TestPivot2"); - dynamic field = pivot.PivotFields("Region"); - - // Act - Set orientation WITH RefreshTable() - field.Orientation = xlRowField; - pivot.RefreshTable(); - - // Assert - int actualOrientation = Convert.ToInt32(field.Orientation); - _output.WriteLine($"AddRowField WITH RefreshTable:"); - _output.WriteLine($" Expected Orientation: {xlRowField} (xlRowField)"); - _output.WriteLine($" Actual Orientation: {actualOrientation}"); - _output.WriteLine($" Match: {actualOrientation == xlRowField}"); - - dynamic pivotSheet = _workbook.Worksheets.Item($"Pivot_TestPivot2"); - string cellA4Value = pivotSheet.Range["A4"].Value2?.ToString() ?? "(empty)"; - _output.WriteLine($" Cell A4 value: {cellA4Value}"); - - Assert.Equal(xlRowField, actualOrientation); - } - - [Fact] - public void AddValueField_WithoutRefresh_VerifyDataAppears() - { - // Arrange - var pivot = CreatePivotTable("TestPivot3"); - dynamic rowField = pivot.PivotFields("Region"); - rowField.Orientation = xlRowField; - - dynamic valueField = pivot.PivotFields("Sales"); - - // Act - Add value field WITHOUT RefreshTable() - valueField.Orientation = xlDataField; - valueField.Function = xlSum; - // NO pivot.RefreshTable(); - - // Assert - int actualOrientation = Convert.ToInt32(valueField.Orientation); - _output.WriteLine($"AddValueField WITHOUT RefreshTable:"); - _output.WriteLine($" Expected Orientation: {xlDataField} (xlDataField)"); - _output.WriteLine($" Actual Orientation: {actualOrientation}"); - - // Check if sum values appear - dynamic pivotSheet = _workbook.Worksheets.Item($"Pivot_TestPivot3"); - var cellB4 = pivotSheet.Range["B4"].Value2; - _output.WriteLine($" Cell B4 (should have sum): {cellB4}"); - - Assert.Equal(xlDataField, actualOrientation); - } - - [Fact] - public void RemoveField_WithoutRefresh_VerifyFieldHidden() - { - // Arrange - var pivot = CreatePivotTable("TestPivot4"); - dynamic field = pivot.PivotFields("Region"); - field.Orientation = xlRowField; - pivot.RefreshTable(); // Initial setup with refresh - - // Act - Remove field WITHOUT RefreshTable() - field.Orientation = xlHidden; - // NO pivot.RefreshTable(); - - // Assert - int actualOrientation = Convert.ToInt32(field.Orientation); - _output.WriteLine($"RemoveField WITHOUT RefreshTable:"); - _output.WriteLine($" Expected Orientation: {xlHidden} (xlHidden)"); - _output.WriteLine($" Actual Orientation: {actualOrientation}"); - _output.WriteLine($" Match: {actualOrientation == xlHidden}"); - - Assert.Equal(xlHidden, actualOrientation); - } - - [Fact] - public void SetNumberFormat_WithoutRefresh_VerifyFormatApplied() - { - // Arrange - var pivot = CreatePivotTable("TestPivot5"); - dynamic rowField = pivot.PivotFields("Region"); - rowField.Orientation = xlRowField; - - dynamic valueField = pivot.PivotFields("Sales"); - valueField.Orientation = xlDataField; - valueField.Function = xlSum; - pivot.RefreshTable(); // Initial setup - - // Act - Set number format WITHOUT RefreshTable() - string formatBefore = valueField.NumberFormat?.ToString() ?? "(none)"; - valueField.NumberFormat = "$#,##0.00"; - // NO pivot.RefreshTable(); - - // Assert - string formatAfter = valueField.NumberFormat?.ToString() ?? "(none)"; - _output.WriteLine($"SetNumberFormat WITHOUT RefreshTable:"); - _output.WriteLine($" Format Before: {formatBefore}"); - _output.WriteLine($" Format After: {formatAfter}"); - _output.WriteLine($" Match Expected: {formatAfter == "$#,##0.00"}"); - - Assert.Equal("$#,##0.00", formatAfter); - } - - [Fact] - public void ChangeFunction_WithoutRefresh_VerifyFunctionChanged() - { - // Arrange - var pivot = CreatePivotTable("TestPivot6"); - dynamic rowField = pivot.PivotFields("Region"); - rowField.Orientation = xlRowField; - - dynamic valueField = pivot.PivotFields("Sales"); - valueField.Orientation = xlDataField; - valueField.Function = xlSum; - pivot.RefreshTable(); // Initial setup - - // Capture value with SUM - dynamic pivotSheet = _workbook.Worksheets.Item($"Pivot_TestPivot6"); - var sumValue = pivotSheet.Range["B4"].Value2; - _output.WriteLine($"Value with SUM: {sumValue}"); - - // Act - Change function WITHOUT RefreshTable() - valueField.Function = xlCount; - // NO pivot.RefreshTable(); - - // Assert - int actualFunction = Convert.ToInt32(valueField.Function); - var countValue = pivotSheet.Range["B4"].Value2; - - _output.WriteLine($"ChangeFunction WITHOUT RefreshTable:"); - _output.WriteLine($" Expected Function: {xlCount} (xlCount)"); - _output.WriteLine($" Actual Function: {actualFunction}"); - _output.WriteLine($" Value after COUNT: {countValue}"); - _output.WriteLine($" Values different: {!Equals(sumValue, countValue)}"); - - Assert.Equal(xlCount, actualFunction); - } - - [Fact] - public void MultipleOperations_WithoutRefresh_VerifyAllApplied() - { - // Arrange - var pivot = CreatePivotTable("TestPivot7"); - - // Act - Multiple operations WITHOUT RefreshTable() between them - dynamic regionField = pivot.PivotFields("Region"); - regionField.Orientation = xlRowField; - - dynamic productField = pivot.PivotFields("Product"); - productField.Orientation = xlColumnField; - - dynamic salesField = pivot.PivotFields("Sales"); - salesField.Orientation = xlDataField; - salesField.Function = xlSum; - salesField.NumberFormat = "$#,##0"; - - // NO RefreshTable() at all - - // Assert - _output.WriteLine($"Multiple Operations WITHOUT any RefreshTable:"); - _output.WriteLine($" Region Orientation: {regionField.Orientation} (expected {xlRowField})"); - _output.WriteLine($" Product Orientation: {productField.Orientation} (expected {xlColumnField})"); - _output.WriteLine($" Sales Orientation: {salesField.Orientation} (expected {xlDataField})"); - _output.WriteLine($" Sales NumberFormat: {salesField.NumberFormat}"); - - Assert.Equal(xlRowField, Convert.ToInt32(regionField.Orientation)); - Assert.Equal(xlColumnField, Convert.ToInt32(productField.Orientation)); - Assert.Equal(xlDataField, Convert.ToInt32(salesField.Orientation)); - } - - [Fact] - public void Persistence_WithoutRefresh_VerifySaveAndReopen() - { - // Arrange - var pivot = CreatePivotTable("TestPivot8"); - - dynamic regionField = pivot.PivotFields("Region"); - regionField.Orientation = xlRowField; - - dynamic salesField = pivot.PivotFields("Sales"); - salesField.Orientation = xlDataField; - salesField.NumberFormat = "0.00%"; - - // NO RefreshTable() - - // Save and close - _workbook.SaveAs(_testFile); - _workbook.Close(false); - Marshal.ReleaseComObject(_workbook); - _workbook = null; - - // Reopen - _workbook = _excel.Workbooks.Open(_testFile); - dynamic reopenedPivot = _workbook.Worksheets["Pivot_TestPivot8"].PivotTables("TestPivot8"); - - // Assert - Check if settings persisted - dynamic reopenedRegion = reopenedPivot.PivotFields("Region"); - dynamic reopenedSales = reopenedPivot.PivotFields("Sales"); - - _output.WriteLine($"Persistence WITHOUT RefreshTable:"); - _output.WriteLine($" Region Orientation after reopen: {reopenedRegion.Orientation}"); - _output.WriteLine($" Sales Orientation after reopen: {reopenedSales.Orientation}"); - _output.WriteLine($" Sales NumberFormat after reopen: {reopenedSales.NumberFormat}"); - - // The question: do these persist without RefreshTable()? - // FINDING: Row field orientation DOES persist, but value field orientation does NOT - // This proves RefreshTable() IS needed for value fields to persist properly - Assert.Equal(xlRowField, Convert.ToInt32(reopenedRegion.Orientation)); - // Value field did NOT persist - this is expected without RefreshTable() - Assert.Equal(xlHidden, Convert.ToInt32(reopenedSales.Orientation)); // 0 = Hidden, not 4 = DataField - } - - [Fact] - public void Persistence_WithRefresh_VerifySaveAndReopen() - { - // Arrange - var pivot = CreatePivotTable("TestPivot9"); - - dynamic regionField = pivot.PivotFields("Region"); - regionField.Orientation = xlRowField; - - dynamic salesField = pivot.PivotFields("Sales"); - salesField.Orientation = xlDataField; - - // CRITICAL: RefreshTable after structure changes, BEFORE setting visual properties - pivot.RefreshTable(); - - // Now set visual properties - salesField.NumberFormat = "0.00%"; - - // Save and close - _workbook.SaveAs(_testFile); - _workbook.Close(false); - Marshal.ReleaseComObject(_workbook); - _workbook = null; - - // Reopen - _workbook = _excel.Workbooks.Open(_testFile); - dynamic reopenedPivot = _workbook.Worksheets["Pivot_TestPivot9"].PivotTables("TestPivot9"); - - // Assert - Check if settings persisted - dynamic reopenedRegion = reopenedPivot.PivotFields("Region"); - dynamic reopenedSales = reopenedPivot.PivotFields("Sum of Sales"); // Name changes after refresh! - - _output.WriteLine($"Persistence WITH RefreshTable (before visual props):"); - _output.WriteLine($" Region Orientation after reopen: {reopenedRegion.Orientation}"); - _output.WriteLine($" Sum of Sales Orientation after reopen: {reopenedSales.Orientation}"); - _output.WriteLine($" Sum of Sales NumberFormat after reopen: {reopenedSales.NumberFormat}"); - - Assert.Equal(xlRowField, Convert.ToInt32(reopenedRegion.Orientation)); - Assert.Equal(xlDataField, Convert.ToInt32(reopenedSales.Orientation)); - Assert.Equal("0.00%", reopenedSales.NumberFormat?.ToString()); - } - - [Fact] - public void FunctionChange_WithoutRefresh_VerifyPersistence() - { - // Arrange - Create PivotTable with value field using SUM - var pivot = CreatePivotTable("TestPivot10"); - dynamic rowField = pivot.PivotFields("Region"); - rowField.Orientation = xlRowField; - - dynamic valueField = pivot.PivotFields("Sales"); - valueField.Orientation = xlDataField; - valueField.Function = xlSum; - pivot.RefreshTable(); // Initial setup - structure must be refreshed - - // Act - Change function WITHOUT RefreshTable() - dynamic dataField = pivot.DataFields[1]; // Get from DataFields collection - dataField.Function = xlAverage; - // NO pivot.RefreshTable(); - - // Save and close - _workbook.SaveAs(_testFile); - _workbook.Close(false); - Marshal.ReleaseComObject(_workbook); - _workbook = null; - - // Reopen - _workbook = _excel.Workbooks.Open(_testFile); - dynamic reopenedPivot = _workbook.Worksheets["Pivot_TestPivot10"].PivotTables("TestPivot10"); - dynamic reopenedDataField = reopenedPivot.DataFields[1]; - int reopenedFunction = Convert.ToInt32(reopenedDataField.Function); - - _output.WriteLine($"Function Change WITHOUT RefreshTable - Persistence Test:"); - _output.WriteLine($" Expected Function: {xlAverage} (xlAverage)"); - _output.WriteLine($" Actual Function: {reopenedFunction}"); - _output.WriteLine($" Persisted: {reopenedFunction == xlAverage}"); - - // Question: Does function change persist without RefreshTable()? - // If this fails, RefreshTable() IS required after SetFieldFunction - Assert.Equal(xlAverage, reopenedFunction); - } - - [Fact] - public void Filter_WithoutRefresh_VerifyPersistence() - { - // Arrange - Create PivotTable with row field - var pivot = CreatePivotTable("TestPivot11"); - dynamic rowField = pivot.PivotFields("Region"); - rowField.Orientation = xlRowField; - pivot.RefreshTable(); // Initial setup - - // Act - Apply filter WITHOUT RefreshTable() - // Set only "North" visible - dynamic items = rowField.PivotItems(); - for (int i = 1; i <= items.Count; i++) - { - dynamic item = items[i]; - string itemName = item.Name?.ToString() ?? ""; - item.Visible = (itemName == "North"); - } - // NO pivot.RefreshTable(); - - // Save and close - _workbook.SaveAs(_testFile); - _workbook.Close(false); - Marshal.ReleaseComObject(_workbook); - _workbook = null; - - // Reopen - _workbook = _excel.Workbooks.Open(_testFile); - dynamic reopenedPivot = _workbook.Worksheets["Pivot_TestPivot11"].PivotTables("TestPivot11"); - dynamic reopenedField = reopenedPivot.PivotFields("Region"); - dynamic reopenedItems = reopenedField.PivotItems(); - - int visibleCount = 0; - string visibleItemName = ""; - for (int i = 1; i <= reopenedItems.Count; i++) - { - dynamic item = reopenedItems[i]; - if (item.Visible) - { - visibleCount++; - visibleItemName = item.Name?.ToString() ?? ""; - } - } - - _output.WriteLine($"Filter WITHOUT RefreshTable - Persistence Test:"); - _output.WriteLine($" Expected: Only 'North' visible"); - _output.WriteLine($" Visible count: {visibleCount}"); - _output.WriteLine($" Visible item: {visibleItemName}"); - - // Question: Does filter persist without RefreshTable()? - // If this fails, RefreshTable() IS required after SetFieldFilter - Assert.Equal(1, visibleCount); - Assert.Equal("North", visibleItemName); - } - - public void Dispose() - { - GC.SuppressFinalize(this); - try - { - if (_workbook != null) - { - _workbook.Close(false); - Marshal.ReleaseComObject(_workbook); - } - } -#pragma warning disable CA1031 // Intentional: cleanup code must not throw - catch (Exception) { /* Ignore cleanup errors */ } -#pragma warning restore CA1031 - - try - { - if (_excel != null) - { - _excel.Quit(); - Marshal.ReleaseComObject(_excel); - } - } -#pragma warning disable CA1031 // Intentional: cleanup code must not throw - catch (Exception) { /* Ignore cleanup errors */ } -#pragma warning restore CA1031 - - // Clean up test file - try - { - if (File.Exists(_testFile)) - File.Delete(_testFile); - } -#pragma warning disable CA1031 // Intentional: cleanup code must not throw - catch (Exception) { /* Ignore file cleanup errors */ } -#pragma warning restore CA1031 - - GC.Collect(); - GC.WaitForPendingFinalizers(); - } -} - - - - diff --git a/tests/ExcelMcp.Diagnostics.Tests/Integration/Diagnostics/PowerQueryComApiBehaviorTests.cs b/tests/ExcelMcp.Diagnostics.Tests/Integration/Diagnostics/PowerQueryComApiBehaviorTests.cs deleted file mode 100644 index 212ebd87..00000000 --- a/tests/ExcelMcp.Diagnostics.Tests/Integration/Diagnostics/PowerQueryComApiBehaviorTests.cs +++ /dev/null @@ -1,2262 +0,0 @@ -// ============================================================================= -// DIAGNOSTIC TESTS - Direct Excel COM API Behavior -// ============================================================================= -// Purpose: Understand what Excel COM API actually does, without our abstractions -// These tests document the REAL behavior of Excel's Power Query COM API -// ============================================================================= - -using System.Runtime.InteropServices; -using Sbroenne.ExcelMcp.ComInterop; -using Sbroenne.ExcelMcp.Core.Tests.Helpers; -using Xunit; -using Xunit.Abstractions; - -namespace Sbroenne.ExcelMcp.Diagnostics.Tests.Integration.Diagnostics; - -/// -/// Diagnostic tests for Power Query COM API behavior. -/// These tests use raw COM calls to understand Excel's actual behavior. -/// -[Trait("Category", "Integration")] -[Trait("Speed", "Slow")] -[Trait("Layer", "Diagnostics")] -[Trait("Feature", "PowerQuery")] -[Trait("RequiresExcel", "true")] -[Trait("RunType", "OnDemand")] -public class PowerQueryComApiBehaviorTests : IClassFixture, IDisposable -{ - private readonly string _tempDir; - private readonly ITestOutputHelper _output; - private dynamic? _excel; - private dynamic? _workbook; - private readonly string _testFile; - - // Simple M code that creates inline data - private const string SimpleQuery = """ - let - Source = #table({"Name", "Value"}, {{"A", 1}, {"B", 2}, {"C", 3}}) - in - Source - """; - - private const string ModifiedQuery = """ - let - Source = #table({"Name", "Value", "Extra"}, {{"A", 1, "X"}, {"B", 2, "Y"}, {"C", 3, "Z"}}) - in - Source - """; - - private const string ColumnRemovedQuery = """ - let - Source = #table({"Name"}, {{"A"}, {"B"}, {"C"}}) - in - Source - """; - - public PowerQueryComApiBehaviorTests(TempDirectoryFixture fixture, ITestOutputHelper output) - { - _tempDir = fixture.TempDir; - _output = output; - _testFile = Path.Combine(_tempDir, $"PQDiag_{Guid.NewGuid():N}.xlsx"); - - // Create Excel instance directly via COM - var excelType = Type.GetTypeFromProgID("Excel.Application"); - _excel = Activator.CreateInstance(excelType!); - _excel.Visible = false; - _excel.DisplayAlerts = false; - - // Create new workbook - _workbook = _excel.Workbooks.Add(); - _workbook.SaveAs(_testFile); - - _output.WriteLine($"Test file: {_testFile}"); - } - - public void Dispose() - { - try - { - if (_workbook != null) - { - _workbook.Close(false); - ComUtilities.Release(ref _workbook); - } - if (_excel != null) - { - _excel.Quit(); - ComUtilities.Release(ref _excel); - } - } - catch (Exception ex) - { - _output.WriteLine($"Cleanup error: {ex.Message}"); - } - GC.SuppressFinalize(this); - GC.Collect(); - GC.WaitForPendingFinalizers(); - } - - // ========================================================================= - // SCENARIO 1: Basic Query Creation - Load to Table - // ========================================================================= - - [Fact] - public void Scenario1_CreateQuery_LoadToTable() - { - _output.WriteLine("=== SCENARIO 1: Create Query → Load to Table ==="); - - // Step 1: Add query to Queries collection - dynamic? queries = null; - dynamic? query = null; - dynamic? sheets = null; - dynamic? sheet = null; - dynamic? listObjects = null; - - try - { - queries = _workbook.Queries; - int initialCount = queries.Count; - _output.WriteLine($"Initial query count: {initialCount}"); - - // Add query - this creates the query definition only - query = queries.Add("TestQuery", SimpleQuery); - _output.WriteLine($"Query added. Name: {query.Name}"); - _output.WriteLine($"Query count after add: {queries.Count}"); - - // Check: Does adding a query automatically create a table? NO - sheets = _workbook.Worksheets; - sheet = sheets[1]; - listObjects = sheet.ListObjects; - _output.WriteLine($"ListObjects count after query add: {listObjects.Count}"); - - Assert.Equal(initialCount + 1, (int)queries.Count); - Assert.Equal(0, (int)listObjects.Count); // Query alone doesn't create table - - // Step 2: To load to table, we need to create a QueryTable - _output.WriteLine("\n--- Creating QueryTable to load data ---"); - - dynamic? range = null; - dynamic? queryTables = null; - dynamic? qt = null; - - try - { - range = sheet.Range["A1"]; - queryTables = sheet.QueryTables; - - string connectionString = $"OLEDB;Provider=Microsoft.Mashup.OleDb.1;Data Source=$Workbook$;Location=TestQuery"; - qt = queryTables.Add(connectionString, range); - qt.CommandType = 2; // xlCmdSql - qt.CommandText = "SELECT * FROM [TestQuery]"; - - _output.WriteLine("QueryTable created. Refreshing..."); - qt.Refresh(false); // false = synchronous - - _output.WriteLine($"QueryTable refreshed. RowNumbers: {qt.ResultRange?.Rows?.Count}"); - - // Check ListObjects now - int listObjectCount = listObjects.Count; - _output.WriteLine($"ListObjects count after refresh: {listObjectCount}"); - - // Document behavior - if (listObjectCount > 0) - { - dynamic? lo = listObjects[1]; - _output.WriteLine($"ListObject name: {lo?.Name}"); - ComUtilities.Release(ref lo); - } - } - finally - { - ComUtilities.Release(ref qt); - ComUtilities.Release(ref queryTables); - ComUtilities.Release(ref range); - } - } - finally - { - ComUtilities.Release(ref listObjects); - ComUtilities.Release(ref sheet); - ComUtilities.Release(ref sheets); - ComUtilities.Release(ref query); - ComUtilities.Release(ref queries); - } - - _output.WriteLine("=== SCENARIO 1 COMPLETE ===\n"); - } - - // ========================================================================= - // SCENARIO 2: Update Query (Add Column) - // ========================================================================= - - [Fact] - public void Scenario2_UpdateQuery_AddColumn() - { - _output.WriteLine("=== SCENARIO 2: Update Query → Add Column ==="); - - dynamic? queries = null; - dynamic? query = null; - - try - { - queries = _workbook.Queries; - - // Create and load query first - query = queries.Add("UpdateTest", SimpleQuery); - LoadQueryToTable("UpdateTest", "A1"); - - int colsBefore = GetFirstTableColumnCount(); - _output.WriteLine($"Original columns: {colsBefore}"); - _output.WriteLine($"Original formula length: {((string)query.Formula).Length}"); - - // Update the formula - NetOffice shows this is just a property set - _output.WriteLine("\n--- Updating query formula ---"); - query.Formula = ModifiedQuery; - - _output.WriteLine($"New formula length: {((string)query.Formula).Length}"); - _output.WriteLine($"Formula updated successfully: {query.Formula.Contains("Extra")}"); - - // Key question: Does the table automatically update? NO - need refresh - _output.WriteLine("\n--- Checking if table auto-updates (it shouldn't) ---"); - int colsAfterUpdate = GetFirstTableColumnCount(); - _output.WriteLine($"Columns after formula update (before refresh): {colsAfterUpdate}"); - _output.WriteLine($"Table auto-updated? {colsAfterUpdate != colsBefore}"); - - // Refresh to see new column - _output.WriteLine("\n--- Refreshing table ---"); - RefreshFirstTable(); - - int colsAfterRefresh = GetFirstTableColumnCount(); - _output.WriteLine($"Columns after refresh: {colsAfterRefresh}"); - _output.WriteLine($"New column appeared? {colsAfterRefresh > colsBefore}"); - } - finally - { - ComUtilities.Release(ref query); - ComUtilities.Release(ref queries); - } - - _output.WriteLine("=== SCENARIO 2 COMPLETE ===\n"); - } - - // ========================================================================= - // SCENARIO 3: Update Query (Remove Column) - // ========================================================================= - - [Fact] - public void Scenario3_UpdateQuery_RemoveColumn() - { - _output.WriteLine("=== SCENARIO 3: Update Query → Remove Column ==="); - - dynamic? queries = null; - dynamic? query = null; - - try - { - queries = _workbook.Queries; - query = queries.Add("RemoveColTest", SimpleQuery); - LoadQueryToTable("RemoveColTest", "A1"); - - int colsBefore = GetFirstTableColumnCount(); - _output.WriteLine($"Original columns: {colsBefore}"); - - // Remove a column - _output.WriteLine("\n--- Updating query to remove column ---"); - query.Formula = ColumnRemovedQuery; - _output.WriteLine("Updated to 1 column (Name only)"); - - // Refresh to apply schema change - _output.WriteLine("\n--- Refreshing table ---"); - RefreshFirstTable(); - - int colsAfterRefresh = GetFirstTableColumnCount(); - _output.WriteLine($"Columns after refresh: {colsAfterRefresh}"); - _output.WriteLine($"Column removed? {colsAfterRefresh < colsBefore}"); - } - finally - { - ComUtilities.Release(ref query); - ComUtilities.Release(ref queries); - } - - _output.WriteLine("=== SCENARIO 3 COMPLETE ===\n"); - } - - // ========================================================================= - // SCENARIO 4: Delete Query - What Happens to Table? - // ========================================================================= - - [Fact] - public void Scenario4_DeleteQuery_TableBehavior() - { - _output.WriteLine("=== SCENARIO 4: Delete Query → What Happens to Table? ==="); - - dynamic? queries = null; - dynamic? query = null; - dynamic? sheet = null; - dynamic? listObjects = null; - - try - { - queries = _workbook.Queries; - query = queries.Add("DeleteTest", SimpleQuery); - LoadQueryToTable("DeleteTest", "A1"); - - sheet = _workbook.Worksheets[1]; - listObjects = sheet.ListObjects; - - int tableCountBefore = listObjects.Count; - _output.WriteLine($"Tables before delete: {tableCountBefore}"); - - // Get table name before delete - string? tableName = null; - if (tableCountBefore > 0) - { - dynamic? lo = listObjects[1]; - tableName = lo.Name; - _output.WriteLine($"Table name: {tableName}"); - ComUtilities.Release(ref lo); - } - - // DELETE THE QUERY - Key test! - _output.WriteLine("\n--- Deleting query ---"); - query.Delete(); - ComUtilities.Release(ref query); - query = null; - - _output.WriteLine($"Query count after delete: {queries.Count}"); - - // KEY QUESTION: What happened to the table? - ComUtilities.Release(ref listObjects); - listObjects = sheet.ListObjects; - int tableCountAfter = listObjects.Count; - _output.WriteLine($"Tables after delete: {tableCountAfter}"); - - if (tableCountAfter > 0) - { - _output.WriteLine("TABLE SURVIVES! Query deletion does NOT delete the table."); - dynamic? lo = listObjects[1]; - _output.WriteLine($"Orphaned table name: {lo.Name}"); - - // Can we still access the data? - dynamic? dataRange = lo.DataBodyRange; - if (dataRange != null) - { - _output.WriteLine($"Data rows: {dataRange.Rows.Count}"); - ComUtilities.Release(ref dataRange); - } - ComUtilities.Release(ref lo); - } - else - { - _output.WriteLine("TABLE DELETED! Query deletion removes the table too."); - } - } - finally - { - ComUtilities.Release(ref listObjects); - ComUtilities.Release(ref sheet); - ComUtilities.Release(ref query); - ComUtilities.Release(ref queries); - } - - _output.WriteLine("=== SCENARIO 4 COMPLETE ===\n"); - } - - // ========================================================================= - // SCENARIO 5: Re-create Query After Delete - // ========================================================================= - - [Fact] - public void Scenario5_RecreateQueryAfterDelete() - { - _output.WriteLine("=== SCENARIO 5: Re-create Query After Delete ==="); - - dynamic? queries = null; - dynamic? query = null; - - try - { - queries = _workbook.Queries; - - // Create, load, delete - query = queries.Add("RecreateTest", SimpleQuery); - LoadQueryToTable("RecreateTest", "A1"); - query.Delete(); - ComUtilities.Release(ref query); - query = null; - - _output.WriteLine("Query deleted. Attempting to recreate with same name..."); - - // Can we recreate with same name? - try - { - query = queries.Add("RecreateTest", ModifiedQuery); - _output.WriteLine($"SUCCESS: Query recreated. Name: {query.Name}"); - - // Can we load it again? - LoadQueryToTable("RecreateTest", "E1"); // Different location - _output.WriteLine("Query loaded to new location successfully"); - } - catch (COMException ex) - { - _output.WriteLine($"FAILED to recreate: {ex.Message}"); - } - } - finally - { - ComUtilities.Release(ref query); - ComUtilities.Release(ref queries); - } - - _output.WriteLine("=== SCENARIO 5 COMPLETE ===\n"); - } - - // ========================================================================= - // SCENARIO 6: Change to Connection Only - // ========================================================================= - - [Fact] - public void Scenario6_ChangeToConnectionOnly() - { - _output.WriteLine("=== SCENARIO 6: Change Query to Connection Only ==="); - - dynamic? queries = null; - dynamic? query = null; - dynamic? sheet = null; - dynamic? listObjects = null; - dynamic? connections = null; - - try - { - queries = _workbook.Queries; - query = queries.Add("ConnOnlyTest", SimpleQuery); - - // First load to table - LoadQueryToTable("ConnOnlyTest", "A1"); - - sheet = _workbook.Worksheets[1]; - listObjects = sheet.ListObjects; - _output.WriteLine($"Tables after initial load: {listObjects.Count}"); - - // Now how do we change to "connection only"? - // In Excel UI, this means the query exists but doesn't load anywhere - _output.WriteLine("\n--- Attempting to change to connection only ---"); - - // Option 1: Delete the ListObject but keep the query - if (listObjects.Count > 0) - { - dynamic? lo = listObjects[1]; - string loName = lo.Name; - _output.WriteLine($"Deleting ListObject: {loName}"); - - // Unlist converts table to range - lo.Unlist(); - ComUtilities.Release(ref lo); - - _output.WriteLine("ListObject deleted (Unlist called)"); - } - - // Verify query still exists - ComUtilities.Release(ref listObjects); - listObjects = sheet.ListObjects; - _output.WriteLine($"Tables after Unlist: {listObjects.Count}"); - _output.WriteLine($"Queries count: {queries.Count}"); - - // Check connection status - connections = _workbook.Connections; - _output.WriteLine($"Connections count: {connections.Count}"); - - for (int i = 1; i <= connections.Count; i++) - { - dynamic? conn = connections[i]; - _output.WriteLine($"Connection {i}: {conn.Name}, Type: {conn.Type}"); - ComUtilities.Release(ref conn); - } - } - finally - { - ComUtilities.Release(ref connections); - ComUtilities.Release(ref listObjects); - ComUtilities.Release(ref sheet); - ComUtilities.Release(ref query); - ComUtilities.Release(ref queries); - } - - _output.WriteLine("=== SCENARIO 6 COMPLETE ===\n"); - } - - // ========================================================================= - // SCENARIO 7: Query Error Handling - // ========================================================================= - - [Fact] - public void Scenario7_QueryWithError() - { - _output.WriteLine("=== SCENARIO 7: Query With Error ==="); - - const string errorQuery = """ - let - Source = NonExistentFunction() - in - Source - """; - - dynamic? queries = null; - dynamic? query = null; - - try - { - queries = _workbook.Queries; - - // Can we add a query with invalid M code? - _output.WriteLine("Adding query with invalid M code..."); - query = queries.Add("ErrorQuery", errorQuery); - _output.WriteLine($"Query added successfully (no validation on add)"); - - // Error should occur on refresh - _output.WriteLine("\n--- Attempting to load (should fail) ---"); - try - { - LoadQueryToTable("ErrorQuery", "A1"); - _output.WriteLine("UNEXPECTED: Query loaded without error"); - } - catch (COMException ex) - { - _output.WriteLine($"EXPECTED ERROR on refresh: 0x{ex.HResult:X8}"); - _output.WriteLine($"Message: {ex.Message}"); - } - - // Query should still exist despite error - _output.WriteLine($"\nQueries count after error: {queries.Count}"); - } - finally - { - ComUtilities.Release(ref query); - ComUtilities.Release(ref queries); - } - - _output.WriteLine("=== SCENARIO 7 COMPLETE ===\n"); - } - - // ========================================================================= - // SCENARIO 8: Refresh Behavior - // ========================================================================= - - [Fact] - public void Scenario8_RefreshBehavior() - { - _output.WriteLine("=== SCENARIO 8: Refresh Behavior ==="); - - dynamic? queries = null; - dynamic? query = null; - dynamic? connections = null; - - try - { - queries = _workbook.Queries; - query = queries.Add("RefreshTest", SimpleQuery); - LoadQueryToTable("RefreshTest", "A1"); - - connections = _workbook.Connections; - _output.WriteLine($"Connections: {connections.Count}"); - - // Find the connection for this query - dynamic? pqConnection = null; - for (int i = 1; i <= connections.Count; i++) - { - dynamic? conn = connections[i]; - string connName = conn.Name; - if (connName.Contains("RefreshTest")) - { - pqConnection = conn; - _output.WriteLine($"Found connection: {connName}"); - break; - } - ComUtilities.Release(ref conn); - } - - if (pqConnection != null) - { - // Test different refresh methods - _output.WriteLine("\n--- Testing connection.Refresh() ---"); - try - { - pqConnection.Refresh(); - _output.WriteLine("connection.Refresh() succeeded"); - } - catch (COMException ex) - { - _output.WriteLine($"connection.Refresh() failed: {ex.Message}"); - } - - ComUtilities.Release(ref pqConnection); - } - - // Also test RefreshAll - _output.WriteLine("\n--- Testing workbook.RefreshAll() ---"); - try - { - _workbook.RefreshAll(); - _output.WriteLine("RefreshAll() called (async - may not complete immediately)"); - } - catch (COMException ex) - { - _output.WriteLine($"RefreshAll() failed: {ex.Message}"); - } - } - finally - { - ComUtilities.Release(ref connections); - ComUtilities.Release(ref query); - ComUtilities.Release(ref queries); - } - - _output.WriteLine("=== SCENARIO 8 COMPLETE ===\n"); - } - - // ========================================================================= - // SCENARIO 9: Multiple Queries - // ========================================================================= - - [Fact] - public void Scenario9_MultipleQueries() - { - _output.WriteLine("=== SCENARIO 9: Multiple Queries ==="); - - dynamic? queries = null; - dynamic? query1 = null; - dynamic? query2 = null; - dynamic? query3 = null; - - try - { - queries = _workbook.Queries; - - // Create multiple queries - query1 = queries.Add("Query1", SimpleQuery); - query2 = queries.Add("Query2", ModifiedQuery); - query3 = queries.Add("Query3", ColumnRemovedQuery); - - _output.WriteLine($"Created 3 queries. Total: {queries.Count}"); - - // Load each to different locations - LoadQueryToTable("Query1", "A1"); - LoadQueryToTable("Query2", "E1"); - LoadQueryToTable("Query3", "I1"); - - _output.WriteLine("All queries loaded to tables"); - - // Delete middle query - _output.WriteLine("\n--- Deleting Query2 ---"); - query2.Delete(); - ComUtilities.Release(ref query2); - query2 = null; - - _output.WriteLine($"Queries after delete: {queries.Count}"); - - // Verify other queries still work - query1.Formula = ModifiedQuery; // Update Query1 - RefreshFirstTable(); // Refresh via table instead of connection name - _output.WriteLine("Query1 still works after Query2 deletion"); - } - finally - { - ComUtilities.Release(ref query3); - ComUtilities.Release(ref query2); - ComUtilities.Release(ref query1); - ComUtilities.Release(ref queries); - } - - _output.WriteLine("=== SCENARIO 9 COMPLETE ===\n"); - } - - // ========================================================================= - // SCENARIO 10: Special Characters in Query Name - // ========================================================================= - - [Fact] - public void Scenario10_SpecialCharactersInName() - { - _output.WriteLine("=== SCENARIO 10: Special Characters in Query Name ==="); - - dynamic? queries = null; - - try - { - queries = _workbook.Queries; - - var testNames = new[] - { - "Query With Spaces", - "Query-With-Dashes", - "Query_With_Underscores", - "Query.With.Dots", // Expected to fail - dots not allowed - "Query123Numbers", - // "Query/Slash", // Likely invalid - // "Query:Colon", // Likely invalid - }; - - foreach (var name in testNames) - { - try - { - dynamic? q = queries.Add(name, SimpleQuery); - _output.WriteLine($"✓ Created: '{name}'"); - ComUtilities.Release(ref q); - } - catch (Exception ex) when (ex is COMException || ex is ArgumentException) - { - _output.WriteLine($"✗ Failed: '{name}' - {ex.Message}"); - } - } - - _output.WriteLine($"\nTotal queries created: {queries.Count}"); - } - finally - { - ComUtilities.Release(ref queries); - } - - _output.WriteLine("=== SCENARIO 10 COMPLETE ===\n"); - } - - // ========================================================================= - // SCENARIO 11: Load to Data Model - // ========================================================================= - - [Fact] - public void Scenario11_LoadToDataModel() - { - _output.WriteLine("=== SCENARIO 11: Load to Data Model ==="); - - dynamic? queries = null; - dynamic? query = null; - dynamic? connections = null; - dynamic? model = null; - - try - { - queries = _workbook.Queries; - query = queries.Add("DataModelTest", SimpleQuery); - - // To load to Data Model, we need to use a different approach - // The connection needs CreateModelConnection = true - _output.WriteLine("Query created. Attempting to load to Data Model..."); - - connections = _workbook.Connections; - - // Check if a Power Query connection was auto-created - _output.WriteLine($"Connections after query add: {connections.Count}"); - - // Try to access the Data Model - try - { - model = _workbook.Model; - dynamic? modelTables = model.ModelTables; - _output.WriteLine($"Model tables before load: {modelTables.Count}"); - - // To load to Data Model, we typically need to: - // 1. Create connection with CreateModelConnection = true - // 2. Or use the UI's "Load To..." option - - // Let's try creating a connection that loads to model - string connString = $"OLEDB;Provider=Microsoft.Mashup.OleDb.1;Data Source=$Workbook$;Location=DataModelTest"; - - // Add connection with model flag - try - { - dynamic? newConn = connections.Add2( - "Query - DataModelTest", // Name - "Power Query - DataModelTest", // Description - connString, // ConnectionString - "SELECT * FROM [DataModelTest]", // CommandText - 2, // lCmdtype (xlCmdSql) - true, // CreateModelConnection - KEY! - false // ImportRelationships - ); - _output.WriteLine("Connection with CreateModelConnection=true created"); - - // Refresh to load data - newConn.Refresh(); - _output.WriteLine("Connection refreshed"); - - ComUtilities.Release(ref newConn); - } - catch (COMException ex) - { - _output.WriteLine($"Add2 with model flag failed: 0x{ex.HResult:X8} - {ex.Message}"); - } - - // Check model tables after - ComUtilities.Release(ref modelTables); - modelTables = model.ModelTables; - _output.WriteLine($"Model tables after load attempt: {modelTables.Count}"); - - ComUtilities.Release(ref modelTables); - } - catch (COMException ex) - { - _output.WriteLine($"Data Model access failed: {ex.Message}"); - } - } - finally - { - ComUtilities.Release(ref model); - ComUtilities.Release(ref connections); - ComUtilities.Release(ref query); - ComUtilities.Release(ref queries); - } - - _output.WriteLine("=== SCENARIO 11 COMPLETE ===\n"); - } - - // ========================================================================= - // SCENARIO 12: Unload Data Model Query (Bug Discovery Test) - // ========================================================================= - - [Fact] - public void Scenario12_UnloadDataModelQuery_ConnectionNotRemoved() - { - _output.WriteLine("=== SCENARIO 12: Unload Query Loaded to Data Model Only ==="); - _output.WriteLine("PURPOSE: Verify if Unload (removing worksheet tables) handles Data Model connections\n"); - - dynamic? queries = null; - dynamic? query = null; - dynamic? connections = null; - dynamic? model = null; - - try - { - queries = _workbook.Queries; - query = queries.Add("DataModelUnloadTest", SimpleQuery); - _output.WriteLine("Query created: DataModelUnloadTest"); - - connections = _workbook.Connections; - int connectionsBefore = connections.Count; - - // Load to Data Model ONLY (no worksheet table) - _output.WriteLine("\n--- Loading to Data Model only (no worksheet table) ---"); - string connString = $"OLEDB;Provider=Microsoft.Mashup.OleDb.1;Data Source=$Workbook$;Location=DataModelUnloadTest"; - - try - { - dynamic? modelConn = connections.Add2( - "Query - DataModelUnloadTest", - "Power Query - DataModelUnloadTest", - connString, - "SELECT * FROM [DataModelUnloadTest]", - 2, // xlCmdSql - true, // CreateModelConnection = TRUE (Data Model only) - false // ImportRelationships - ); - modelConn.Refresh(); - _output.WriteLine("Data Model connection created and refreshed"); - ComUtilities.Release(ref modelConn); - } - catch (COMException ex) - { - _output.WriteLine($"Failed to create Data Model connection: {ex.Message}"); - return; - } - - // Verify Data Model has the table - model = _workbook.Model; - dynamic? modelTables = model.ModelTables; - _output.WriteLine($"Model tables after load: {modelTables.Count}"); - ComUtilities.Release(ref modelTables); - - // Verify no ListObjects (worksheet tables) - dynamic? sheet = _workbook.Worksheets[1]; - dynamic? listObjects = sheet.ListObjects; - _output.WriteLine($"Worksheet tables (ListObjects): {listObjects.Count}"); - ComUtilities.Release(ref listObjects); - ComUtilities.Release(ref sheet); - - // Now simulate what our Unload method does: iterate ListObjects and delete them - _output.WriteLine("\n--- Simulating Unload (only checks ListObjects) ---"); - sheet = _workbook.Worksheets[1]; - listObjects = sheet.ListObjects; - int tablesToDelete = 0; - - for (int i = listObjects.Count; i >= 1; i--) - { - tablesToDelete++; - // Our Unload only looks at ListObjects - } - _output.WriteLine($"ListObjects found to unlist: {tablesToDelete}"); - _output.WriteLine("BUG: Unload only checks ListObjects - it IGNORES Data Model connections!"); - - ComUtilities.Release(ref listObjects); - ComUtilities.Release(ref sheet); - - // Check if Data Model connection still exists - _output.WriteLine("\n--- Checking Data Model state after 'Unload' ---"); - ComUtilities.Release(ref connections); - connections = _workbook.Connections; - _output.WriteLine($"Connections count: {connections.Count}"); - - bool modelConnectionExists = false; - for (int i = 1; i <= connections.Count; i++) - { - dynamic? conn = connections[i]; - string connName = conn.Name; - if (connName.Contains("DataModelUnloadTest")) - { - modelConnectionExists = true; - _output.WriteLine($"FINDING: Data Model connection STILL EXISTS: {connName}"); - } - ComUtilities.Release(ref conn); - } - - modelTables = model.ModelTables; - _output.WriteLine($"Model tables after 'Unload': {modelTables.Count}"); - ComUtilities.Release(ref modelTables); - - // Document the bug - _output.WriteLine("\n=== BUG CONFIRMATION ==="); - _output.WriteLine("Our Unload method only iterates through worksheet ListObjects."); - _output.WriteLine("For queries loaded ONLY to Data Model, there are NO ListObjects to unlist."); - _output.WriteLine($"Data Model connection still exists: {modelConnectionExists}"); - _output.WriteLine("Query is NOT connection-only - it's still loaded to Data Model!"); - - Assert.True(modelConnectionExists, "Test proves Data Model connection survives Unload"); - } - finally - { - ComUtilities.Release(ref model); - ComUtilities.Release(ref connections); - ComUtilities.Release(ref query); - ComUtilities.Release(ref queries); - } - - _output.WriteLine("=== SCENARIO 12 COMPLETE ===\n"); - } - - // ========================================================================= - // SCENARIO 13: Unload Query Loaded to Both (Worksheet AND Data Model) - // ========================================================================= - - [Fact] - public void Scenario13_UnloadBothDestinations_OnlyTableRemoved() - { - _output.WriteLine("=== SCENARIO 13: Unload Query Loaded to BOTH Worksheet AND Data Model ==="); - _output.WriteLine("PURPOSE: Verify Unload behavior when query is loaded to both destinations\n"); - - dynamic? queries = null; - dynamic? query = null; - dynamic? connections = null; - dynamic? model = null; - dynamic? sheet = null; - dynamic? listObjects = null; - - try - { - queries = _workbook.Queries; - query = queries.Add("BothDestTest", SimpleQuery); - _output.WriteLine("Query created: BothDestTest"); - - // Step 1: Load to worksheet first - _output.WriteLine("\n--- Step 1: Load to worksheet ---"); - LoadQueryToTable("BothDestTest", "A1"); - - sheet = _workbook.Worksheets[1]; - listObjects = sheet.ListObjects; - _output.WriteLine($"Worksheet tables after load: {listObjects.Count}"); - ComUtilities.Release(ref listObjects); - ComUtilities.Release(ref sheet); - - // Step 2: Also load to Data Model - _output.WriteLine("\n--- Step 2: Also load to Data Model ---"); - connections = _workbook.Connections; - string connString = $"OLEDB;Provider=Microsoft.Mashup.OleDb.1;Data Source=$Workbook$;Location=BothDestTest"; - - try - { - dynamic? modelConn = connections.Add2( - "Query - BothDestTest - Model", // Different name to avoid conflict - "Power Query Model Connection", - connString, - "SELECT * FROM [BothDestTest]", - 2, // xlCmdSql - true, // CreateModelConnection = TRUE - false - ); - modelConn.Refresh(); - _output.WriteLine("Data Model connection created"); - ComUtilities.Release(ref modelConn); - } - catch (COMException ex) - { - _output.WriteLine($"Failed to create Data Model connection: {ex.Message}"); - } - - model = _workbook.Model; - dynamic? modelTables = model.ModelTables; - _output.WriteLine($"Model tables: {modelTables.Count}"); - ComUtilities.Release(ref modelTables); - - // Step 3: Simulate Unload - remove worksheet table - _output.WriteLine("\n--- Step 3: Simulating Unload (removing worksheet table) ---"); - sheet = _workbook.Worksheets[1]; - listObjects = sheet.ListObjects; - - if (listObjects.Count > 0) - { - dynamic? lo = listObjects[1]; - string tableName = lo.Name; - _output.WriteLine($"Unlisting table: {tableName}"); - lo.Unlist(); - ComUtilities.Release(ref lo); - } - - ComUtilities.Release(ref listObjects); - listObjects = sheet.ListObjects; - _output.WriteLine($"Worksheet tables after Unlist: {listObjects.Count}"); - ComUtilities.Release(ref listObjects); - ComUtilities.Release(ref sheet); - - // Step 4: Check Data Model state - _output.WriteLine("\n--- Step 4: Check Data Model state after Unload ---"); - ComUtilities.Release(ref connections); - connections = _workbook.Connections; - - int modelConnectionCount = 0; - for (int i = 1; i <= connections.Count; i++) - { - dynamic? conn = connections[i]; - string connName = conn.Name; - if (connName.Contains("BothDestTest")) - { - modelConnectionCount++; - _output.WriteLine($"Connection still exists: {connName}"); - } - ComUtilities.Release(ref conn); - } - - modelTables = model.ModelTables; - int modelTableCount = modelTables.Count; - _output.WriteLine($"Model tables after Unload: {modelTableCount}"); - ComUtilities.Release(ref modelTables); - - // Document findings - _output.WriteLine("\n=== FINDINGS ==="); - _output.WriteLine($"Worksheet table removed: {listObjects?.Count == 0}"); - _output.WriteLine($"Data Model connections remaining: {modelConnectionCount}"); - _output.WriteLine($"Model tables remaining: {modelTableCount}"); - - if (modelConnectionCount > 0 || modelTableCount > 0) - { - _output.WriteLine("\nBUG: Unload only removes worksheet table, NOT Data Model connection!"); - _output.WriteLine("Query is NOT fully connection-only after Unload."); - } - - Assert.True(modelConnectionCount > 0 || modelTableCount > 0, - "Test proves Data Model content survives Unload"); - } - finally - { - ComUtilities.Release(ref model); - ComUtilities.Release(ref listObjects); - ComUtilities.Release(ref sheet); - ComUtilities.Release(ref connections); - ComUtilities.Release(ref query); - ComUtilities.Release(ref queries); - } - - _output.WriteLine("=== SCENARIO 13 COMPLETE ===\n"); - } - - // ========================================================================= - // SCENARIO 14: Proper Connection-Only Implementation - // ========================================================================= - - [Fact] - public void Scenario14_ProperConnectionOnlyImplementation() - { - _output.WriteLine("=== SCENARIO 14: How to Properly Make a Query Connection-Only ==="); - _output.WriteLine("PURPOSE: Document the CORRECT way to make a query connection-only\n"); - - dynamic? queries = null; - dynamic? query = null; - dynamic? connections = null; - dynamic? model = null; -#pragma warning disable IDE0059 // Unnecessary assignment - required for COM object lifecycle management - dynamic? sheet = null; - dynamic? listObjects = null; -#pragma warning restore IDE0059 - - try - { - queries = _workbook.Queries; - query = queries.Add("FullUnloadTest", SimpleQuery); - - // Load to BOTH destinations - _output.WriteLine("--- Loading to both worksheet AND Data Model ---"); - LoadQueryToTable("FullUnloadTest", "A1"); - - connections = _workbook.Connections; - string connString = $"OLEDB;Provider=Microsoft.Mashup.OleDb.1;Data Source=$Workbook$;Location=FullUnloadTest"; - - try - { - dynamic? modelConn = connections.Add2( - "Query - FullUnloadTest - Model", - "Power Query Model Connection", - connString, - "SELECT * FROM [FullUnloadTest]", - 2, true, false - ); - modelConn.Refresh(); - ComUtilities.Release(ref modelConn); - } - catch (COMException ex) - { - _output.WriteLine($"Model connection creation failed: {ex.Message}"); - } - - sheet = _workbook.Worksheets[1]; - listObjects = sheet.ListObjects; - model = _workbook.Model; - dynamic? modelTables = model.ModelTables; - - _output.WriteLine($"Initial state - Worksheet tables: {listObjects.Count}, Model tables: {modelTables.Count}"); - ComUtilities.Release(ref modelTables); - ComUtilities.Release(ref listObjects); - ComUtilities.Release(ref sheet); - - // PROPER Unload: Remove BOTH worksheet tables AND Data Model connections - _output.WriteLine("\n--- PROPER Unload Implementation ---"); - - // Step 1: Remove worksheet tables (ListObjects) - _output.WriteLine("Step 1: Remove worksheet tables"); - sheet = _workbook.Worksheets[1]; - listObjects = sheet.ListObjects; - for (int i = listObjects.Count; i >= 1; i--) - { - dynamic? lo = listObjects[i]; - dynamic? qt = lo.QueryTable; - if (qt != null) - { - string? connName = null; - try { connName = qt.Connection?.ToString(); } catch (COMException) { /* Connection property may not exist */ } - if (connName?.Contains("FullUnloadTest") == true) - { - _output.WriteLine($" Unlisting table: {lo.Name}"); - lo.Unlist(); - } - ComUtilities.Release(ref qt); - } - ComUtilities.Release(ref lo); - } - ComUtilities.Release(ref listObjects); - ComUtilities.Release(ref sheet); - - // Step 2: Remove Data Model connections (the missing step in our Unload!) - _output.WriteLine("Step 2: Remove Data Model connections"); - ComUtilities.Release(ref connections); - connections = _workbook.Connections; - - // Find and delete connections for this query - var connectionsToDelete = new List(); - for (int i = 1; i <= connections.Count; i++) - { - dynamic? conn = connections[i]; - string connName = conn.Name; - if (connName.Contains("FullUnloadTest")) - { - connectionsToDelete.Add(connName); - } - ComUtilities.Release(ref conn); - } - - foreach (var connName in connectionsToDelete) - { - try - { - dynamic? conn = connections[connName]; - _output.WriteLine($" Deleting connection: {connName}"); - conn.Delete(); - ComUtilities.Release(ref conn); - } - catch (Exception ex) - { - _output.WriteLine($" Failed to delete {connName}: {ex.Message}"); - } - } - - // Verify final state - _output.WriteLine("\n--- Final State (should be connection-only) ---"); - sheet = _workbook.Worksheets[1]; - listObjects = sheet.ListObjects; - _output.WriteLine($"Worksheet tables: {listObjects.Count}"); - - ComUtilities.Release(ref connections); - connections = _workbook.Connections; - int remainingPQConnections = 0; - for (int i = 1; i <= connections.Count; i++) - { - dynamic? conn = connections[i]; - if (((string)conn.Name).Contains("FullUnloadTest")) - { - remainingPQConnections++; - _output.WriteLine($" Remaining connection: {conn.Name}"); - } - ComUtilities.Release(ref conn); - } - _output.WriteLine($"Power Query connections for this query: {remainingPQConnections}"); - - modelTables = model.ModelTables; - _output.WriteLine($"Model tables: {modelTables.Count}"); - - // Verify query still exists - _output.WriteLine($"\nQuery still exists: {queries.Count > 0}"); - _output.WriteLine($"Query name: {query.Name}"); - - bool isConnectionOnly = listObjects.Count == 0 && remainingPQConnections == 0; - _output.WriteLine($"\nIS CONNECTION-ONLY: {isConnectionOnly}"); - - ComUtilities.Release(ref modelTables); - ComUtilities.Release(ref listObjects); - ComUtilities.Release(ref sheet); - - _output.WriteLine("\n=== IMPLEMENTATION REQUIREMENT ==="); - _output.WriteLine("To make a query connection-only, Unload must:"); - _output.WriteLine("1. Remove worksheet tables (ListObjects with matching QueryTable)"); - _output.WriteLine("2. Remove Data Model connections (connections with 'Query - {name}' pattern)"); - _output.WriteLine("3. Keep the query in Workbook.Queries collection"); - } - finally - { - ComUtilities.Release(ref model); - ComUtilities.Release(ref connections); - ComUtilities.Release(ref query); - ComUtilities.Release(ref queries); - } - - _output.WriteLine("=== SCENARIO 14 COMPLETE ===\n"); - } - - // ========================================================================= - // SCENARIO 15: Update Data Model-Only Query - // ========================================================================= - - [Fact] - public void Scenario15_UpdateDataModelOnlyQuery() - { - _output.WriteLine("=== SCENARIO 15: Update Query Loaded ONLY to Data Model ==="); - _output.WriteLine("PURPOSE: Test if Update works for Data Model-only queries (no worksheet table)\n"); - - dynamic? queries = null; - dynamic? query = null; - dynamic? connections = null; - dynamic? model = null; - - try - { - queries = _workbook.Queries; - query = queries.Add("DataModelUpdateTest", SimpleQuery); - _output.WriteLine("Query created: DataModelUpdateTest"); - - // Load to Data Model ONLY (no worksheet table) - _output.WriteLine("\n--- Loading to Data Model only ---"); - connections = _workbook.Connections; - string connString = $"OLEDB;Provider=Microsoft.Mashup.OleDb.1;Data Source=$Workbook$;Location=DataModelUpdateTest"; - - dynamic? modelConn = null; - try - { - modelConn = connections.Add2( - "Query - DataModelUpdateTest", - "Power Query - DataModelUpdateTest", - connString, - "SELECT * FROM [DataModelUpdateTest]", - 2, // xlCmdSql - true, // CreateModelConnection = TRUE (Data Model only) - false // ImportRelationships - ); - modelConn.Refresh(); - _output.WriteLine("Data Model connection created and refreshed"); - } - catch (COMException ex) - { - _output.WriteLine($"Failed to create Data Model connection: {ex.Message}"); - return; - } - - // Verify initial state - Data Model has data - model = _workbook.Model; - dynamic? modelTables = model.ModelTables; - int initialModelTableCount = modelTables.Count; - _output.WriteLine($"Model tables after initial load: {initialModelTableCount}"); - ComUtilities.Release(ref modelTables); - - // Verify NO worksheet tables - dynamic? sheet = _workbook.Worksheets[1]; - dynamic? listObjects = sheet.ListObjects; - _output.WriteLine($"Worksheet tables (should be 0): {listObjects.Count}"); - Assert.Equal(0, (int)listObjects.Count); - ComUtilities.Release(ref listObjects); - ComUtilities.Release(ref sheet); - - // Get original formula - string originalFormula = query.Formula; - _output.WriteLine($"\nOriginal formula contains 'Extra': {originalFormula.Contains("Extra")}"); - - // ========================================================================= - // TEST 1: Update M code (adds a column) - // ========================================================================= - _output.WriteLine("\n--- TEST 1: Update M code (add 'Extra' column) ---"); - query.Formula = ModifiedQuery; - string newFormula = query.Formula; - _output.WriteLine($"Formula updated. Contains 'Extra': {newFormula.Contains("Extra")}"); - Assert.True(newFormula.Contains("Extra"), "Formula should be updated"); - - // ========================================================================= - // TEST 2: What happens WITHOUT refresh? - // ========================================================================= - _output.WriteLine("\n--- TEST 2: Check Data Model WITHOUT refresh ---"); - // At this point, M code is updated but we haven't refreshed - // Question: Is the Data Model stale? - - // We can't easily inspect Data Model column structure via COM, - // but we can check if the connection still exists - bool connectionExists = false; - ComUtilities.Release(ref connections); - connections = _workbook.Connections; - for (int i = 1; i <= connections.Count; i++) - { - dynamic? conn = connections[i]; - if (((string)conn.Name).Contains("DataModelUpdateTest")) - { - connectionExists = true; - _output.WriteLine($"Connection exists: {conn.Name}"); - } - ComUtilities.Release(ref conn); - } - _output.WriteLine($"Connection still exists: {connectionExists}"); - - // ========================================================================= - // TEST 3: Refresh via Connection.Refresh() - // ========================================================================= - _output.WriteLine("\n--- TEST 3: Refresh via connection.Refresh() ---"); - try - { - // Find and refresh the connection - bool refreshed = false; - for (int i = 1; i <= connections.Count; i++) - { - dynamic? conn = connections[i]; - string connName = conn.Name; - if (connName.Contains("DataModelUpdateTest")) - { - _output.WriteLine($"Refreshing connection: {connName}"); - conn.Refresh(); - refreshed = true; - _output.WriteLine("connection.Refresh() succeeded"); - } - ComUtilities.Release(ref conn); - if (refreshed) break; - } - - if (!refreshed) - { - _output.WriteLine("WARNING: No connection found to refresh!"); - } - } - catch (COMException ex) - { - _output.WriteLine($"connection.Refresh() FAILED: 0x{ex.HResult:X8} - {ex.Message}"); - } - - // ========================================================================= - // TEST 4: Verify Data Model state after refresh - // ========================================================================= - _output.WriteLine("\n--- TEST 4: Data Model state after refresh ---"); - modelTables = model.ModelTables; - _output.WriteLine($"Model tables after refresh: {modelTables.Count}"); - - // List ALL model tables and their columns to verify Extra column appeared - bool foundExtraColumn = false; - for (int i = 1; i <= modelTables.Count; i++) - { - dynamic? mt = null; - dynamic? cols = null; - try - { - mt = modelTables[i]; - string tableName = mt.Name; - _output.WriteLine($"Model table {i}: '{tableName}'"); - - cols = mt.ModelTableColumns; - _output.WriteLine($" Column count: {cols.Count}"); - - // List ALL column names - for (int c = 1; c <= cols.Count; c++) - { - dynamic? col = cols[c]; - string colName = col.Name?.ToString() ?? "(null)"; - _output.WriteLine($" Column {c}: {colName}"); - if (colName == "Extra") - { - foundExtraColumn = true; - _output.WriteLine($" ^^^ FOUND 'Extra' column! connection.Refresh() WORKS! ^^^"); - } - ComUtilities.Release(ref col); - } - } - finally - { - ComUtilities.Release(ref cols); - ComUtilities.Release(ref mt); - } - } - ComUtilities.Release(ref modelTables); - - // ========================================================================= - // FINDINGS - // ========================================================================= - _output.WriteLine("\n=== FINDINGS ==="); - _output.WriteLine("1. M code update via query.Formula = works for Data Model queries"); - _output.WriteLine("2. Without refresh, Data Model has STALE data"); - _output.WriteLine($"3. connection.Refresh() propagates column changes: {(foundExtraColumn ? "YES - Extra column found!" : "NO - Extra column NOT found")}"); - _output.WriteLine("4. Our Update.cs currently does NOT refresh Data Model-only queries"); - _output.WriteLine(" (because it only looks for QueryTables on worksheets)"); - - // ASSERT: Extra column should exist after connection.Refresh() - Assert.True(foundExtraColumn, "Extra column should appear in Data Model after connection.Refresh()"); - - ComUtilities.Release(ref modelConn); - } - finally - { - ComUtilities.Release(ref model); - ComUtilities.Release(ref connections); - ComUtilities.Release(ref query); - ComUtilities.Release(ref queries); - } - - _output.WriteLine("=== SCENARIO 15 COMPLETE ===\n"); - } - - // ========================================================================= - // SCENARIO 16: Rename Query - // ========================================================================= - - [Fact] - public void Scenario16_RenameQuery() - { - _output.WriteLine("=== SCENARIO 12: Rename Query ==="); - - dynamic? queries = null; - dynamic? query = null; - dynamic? sheet = null; - dynamic? listObjects = null; - - try - { - queries = _workbook.Queries; - query = queries.Add("OriginalName", SimpleQuery); - LoadQueryToTable("OriginalName", "A1"); - - sheet = _workbook.Worksheets[1]; - listObjects = sheet.ListObjects; - - string? tableNameBefore = null; - if (listObjects.Count > 0) - { - dynamic? lo = listObjects[1]; - tableNameBefore = lo.Name; - _output.WriteLine($"Table name before rename: {tableNameBefore}"); - ComUtilities.Release(ref lo); - } - - // Rename the query - _output.WriteLine("\n--- Renaming query to 'NewName' ---"); - query.Name = "NewName"; - _output.WriteLine($"Query renamed. New name: {query.Name}"); - - // Check if table name changed - ComUtilities.Release(ref listObjects); - listObjects = sheet.ListObjects; - if (listObjects.Count > 0) - { - dynamic? lo = listObjects[1]; - string tableNameAfter = lo.Name; - _output.WriteLine($"Table name after rename: {tableNameAfter}"); - - if (tableNameBefore == tableNameAfter) - { - _output.WriteLine("TABLE NAME DID NOT CHANGE when query was renamed"); - } - else - { - _output.WriteLine("TABLE NAME CHANGED when query was renamed"); - } - ComUtilities.Release(ref lo); - } - - // Can we still refresh via the table? - _output.WriteLine("\n--- Refreshing table after query rename ---"); - try - { - RefreshFirstTable(); - _output.WriteLine("Refresh succeeded after query rename"); - } - catch (Exception ex) - { - _output.WriteLine($"Refresh failed: {ex.Message}"); - } - } - finally - { - ComUtilities.Release(ref listObjects); - ComUtilities.Release(ref sheet); - ComUtilities.Release(ref query); - ComUtilities.Release(ref queries); - } - - _output.WriteLine("=== SCENARIO 12 COMPLETE ===\n"); - } - - // ========================================================================= - // Helper Methods - Direct COM calls - // ========================================================================= - - private void LoadQueryToTable(string queryName, string startCell) - { - dynamic? sheet = null; - dynamic? range = null; - dynamic? listObjects = null; - dynamic? listObject = null; - dynamic? queryTable = null; - - try - { - sheet = _workbook.Worksheets[1]; - range = sheet.Range[startCell]; - listObjects = sheet.ListObjects; - - // Use ListObjects.Add with xlSrcExternal (0) - this creates a proper Excel Table - string connectionString = $"OLEDB;Provider=Microsoft.Mashup.OleDb.1;Data Source=$Workbook$;Location={queryName};Extended Properties=\"\""; - listObject = listObjects.Add( - 0, // SourceType: 0 = xlSrcExternal - connectionString, // Source: connection string - Type.Missing, // LinkSource - 1, // XlListObjectHasHeaders: xlYes - range // Destination: starting cell - ); - - // Configure the QueryTable behind the ListObject - queryTable = listObject.QueryTable; - queryTable.CommandType = 2; // xlCmdSql - queryTable.CommandText = $"SELECT * FROM [{queryName}]"; - queryTable.BackgroundQuery = false; // Synchronous - queryTable.Refresh(false); // Synchronous - } - finally - { - ComUtilities.Release(ref queryTable); - ComUtilities.Release(ref listObject); - ComUtilities.Release(ref listObjects); - ComUtilities.Release(ref range); - ComUtilities.Release(ref sheet); - } - } - - /// - /// Refreshes the first table's QueryTable directly. - /// This is simpler than finding connections by name since ListObjects.Add creates auto-named connections. - /// - private void RefreshFirstTable() - { - dynamic? sheet = null; - dynamic? listObjects = null; - dynamic? listObject = null; - dynamic? queryTable = null; - - try - { - sheet = _workbook.Worksheets[1]; - listObjects = sheet.ListObjects; - - if (listObjects.Count == 0) - { - throw new InvalidOperationException("No tables found to refresh"); - } - - listObject = listObjects[1]; - queryTable = listObject.QueryTable; - queryTable.Refresh(false); // Synchronous refresh - - _output.WriteLine("Table QueryTable refreshed successfully"); - } - finally - { - ComUtilities.Release(ref queryTable); - ComUtilities.Release(ref listObject); - ComUtilities.Release(ref listObjects); - ComUtilities.Release(ref sheet); - } - } - - // ========================================================================= - // SCENARIO 17: Use Add2 for Worksheet Loading (avoid orphaned connections) - // ========================================================================= - - [Fact] - public void Scenario17_UseAdd2ForWorksheetLoading() - { - _output.WriteLine("=== SCENARIO 17: Use Add2 for Worksheet Loading ==="); - _output.WriteLine("PURPOSE: Test if we can use Connections.Add2 with CreateModelConnection=false"); - _output.WriteLine(" and then create a ListObject that uses that named connection\n"); - - dynamic? queries = null; - dynamic? query = null; - dynamic? connections = null; - dynamic? connection = null; - dynamic? sheet = null; - dynamic? listObjects = null; - dynamic? listObject = null; - dynamic? queryTable = null; - dynamic? range = null; - - try - { - // Step 1: Create query - queries = _workbook.Queries; - query = queries.Add("Add2WorksheetTest", SimpleQuery); - _output.WriteLine("Query created: Add2WorksheetTest"); - - // Step 2: Create connection with Add2 (CreateModelConnection = false) - connections = _workbook.Connections; - string connString = $"OLEDB;Provider=Microsoft.Mashup.OleDb.1;Data Source=$Workbook$;Location=Add2WorksheetTest"; - - _output.WriteLine("\n--- Step 2: Create connection with Add2 (CreateModelConnection=false) ---"); - connection = connections.Add2( - "Query - Add2WorksheetTest", // Name - proper naming! - "Power Query - Add2WorksheetTest", // Description - connString, // ConnectionString - "SELECT * FROM [Add2WorksheetTest]", // CommandText - 2, // lCmdtype (xlCmdSql) - false, // CreateModelConnection = FALSE (worksheet, not data model) - false // ImportRelationships - ); - _output.WriteLine($"Connection created: {connection.Name}"); - - // Step 3: Try to create ListObject using this connection - _output.WriteLine("\n--- Step 3: Create ListObject using the named connection ---"); - sheet = _workbook.Worksheets[1]; - range = sheet.Range["A1"]; - listObjects = sheet.ListObjects; - - // Try using the connection name instead of connection string - try - { - // Method 1: Use connection name directly - listObject = listObjects.Add( - 0, // SourceType: 0 = xlSrcExternal - connection, // Source: try passing connection object - Type.Missing, // LinkSource - 1, // XlListObjectHasHeaders: xlYes - range // Destination - ); - _output.WriteLine("ListObjects.Add with connection object SUCCEEDED!"); - } - catch (Exception ex) - { - _output.WriteLine($"ListObjects.Add with connection object FAILED: {ex.Message}"); - - // Method 2: Try with connection string but specifying the connection name - ComUtilities.Release(ref listObject); - try - { - // Use the full connection string but the connection should already exist - string fullConnString = $"OLEDB;Provider=Microsoft.Mashup.OleDb.1;Data Source=$Workbook$;Location=Add2WorksheetTest;Extended Properties=\"\""; - listObject = listObjects.Add( - 0, // SourceType: 0 = xlSrcExternal - fullConnString, // Source: connection string - Type.Missing, // LinkSource - 1, // XlListObjectHasHeaders: xlYes - range // Destination - ); - _output.WriteLine("ListObjects.Add with connection string SUCCEEDED!"); - } - catch (Exception ex2) - { - _output.WriteLine($"ListObjects.Add with connection string ALSO FAILED: {ex2.Message}"); - } - } - - if (listObject != null) - { - // Configure and refresh - queryTable = listObject.QueryTable; - queryTable.CommandType = 2; - queryTable.CommandText = "SELECT * FROM [Add2WorksheetTest]"; - queryTable.BackgroundQuery = false; - - _output.WriteLine("\n--- Step 4: Refresh and check connection name ---"); - queryTable.Refresh(false); - _output.WriteLine("Refresh succeeded!"); - - // Check how many connections exist now - ComUtilities.Release(ref connections); - connections = _workbook.Connections; - _output.WriteLine($"\nConnections after ListObjects.Add:"); - for (int i = 1; i <= connections.Count; i++) - { - dynamic? conn = connections[i]; - _output.WriteLine($" Connection {i}: '{conn.Name}' (Type: {conn.Type})"); - ComUtilities.Release(ref conn); - } - - // Key question: Did ListObjects.Add create ANOTHER connection or use our existing one? - int connectionCount = connections.Count; - if (connectionCount == 1) - { - _output.WriteLine("\nSUCCESS: Only 1 connection exists - ListObjects.Add used our named connection!"); - } - else - { - _output.WriteLine($"\nISSUE: {connectionCount} connections exist - ListObjects.Add created a new one!"); - } - } - } - catch (Exception ex) - { - _output.WriteLine($"ERROR: {ex.Message}"); - } - finally - { - ComUtilities.Release(ref queryTable); - ComUtilities.Release(ref listObject); - ComUtilities.Release(ref listObjects); - ComUtilities.Release(ref range); - ComUtilities.Release(ref sheet); - ComUtilities.Release(ref connection); - ComUtilities.Release(ref connections); - ComUtilities.Release(ref query); - ComUtilities.Release(ref queries); - } - - _output.WriteLine("=== SCENARIO 17 COMPLETE ===\n"); - } - - // ========================================================================= - // SCENARIO 18: List vs View Behavior on Connection-Only Queries - // ========================================================================= - // Bug Report: List works but View fails with 0x800A03EC on complex queries - // This test investigates what operations fail on connection-only queries - - [Fact] - public void Scenario18_ListVsView_ConnectionOnlyQuery() - { - _output.WriteLine("=== SCENARIO 18: List vs View on Connection-Only Query ==="); - _output.WriteLine("PURPOSE: Investigate why List works but View fails with 0x800A03EC\n"); - - // Use a more complex M code that references functions (similar to bug report) - const string complexQuery = """ - let - // Simulates a query with helper function (like fnLoadMilestoneExport in bug report) - fnHelper = (x as number) => x * 2, - Source = #table({"ID", "Name", "Value"}, { - {1, "Item1", fnHelper(100)}, - {2, "Item2", fnHelper(200)}, - {3, "Item3", fnHelper(300)} - }), - AddColumn = Table.AddColumn(Source, "Doubled", each fnHelper([Value])) - in - AddColumn - """; - - dynamic? queries = null; - dynamic? query = null; - dynamic? worksheets = null; - - try - { - queries = _workbook.Queries; - - // STEP 1: Create a connection-only query (no loading to worksheet) - _output.WriteLine("--- STEP 1: Create Connection-Only Query ---"); - query = queries.Add("ComplexConnectionOnly", complexQuery); - _output.WriteLine($"Query created: ComplexConnectionOnly"); - _output.WriteLine($"Query count: {queries.Count}"); - - // STEP 2: Test List-like operations (what List() does) - _output.WriteLine("\n--- STEP 2: Test List-like Operations ---"); - _output.WriteLine("Iterating queries like List() does...\n"); - - for (int i = 1; i <= queries.Count; i++) - { - dynamic? q = null; - try - { - _output.WriteLine($"Query {i}:"); - - // Test accessing queries[i] - like List does - _output.WriteLine($" Calling queries.Item({i})..."); - q = queries[i]; - _output.WriteLine($" SUCCESS: queries.Item({i}) worked"); - - // Test accessing Name property - _output.WriteLine($" Calling q.Name..."); - string name = q.Name?.ToString() ?? "(null)"; - _output.WriteLine($" SUCCESS: Name = '{name}'"); - - // Test accessing Formula property (this is what List catches with try-catch) - _output.WriteLine($" Calling q.Formula..."); - try - { - string formula = q.Formula?.ToString() ?? "(null)"; - _output.WriteLine($" SUCCESS: Formula length = {formula.Length} chars"); - _output.WriteLine($" Formula preview: {formula[..Math.Min(50, formula.Length)]}..."); - } - catch (COMException ex) - { - _output.WriteLine($" FAILED: Formula access threw 0x{ex.HResult:X8}"); - _output.WriteLine($" Message: {ex.Message}"); - } - } - catch (COMException ex) - { - _output.WriteLine($" FAILED at query {i}: 0x{ex.HResult:X8} - {ex.Message}"); - } - finally - { - if (q != null) ComUtilities.Release(ref q!); - } - } - - // STEP 3: Test View-like operations (what View() does differently) - _output.WriteLine("\n--- STEP 3: Test View-like Operations ---"); - _output.WriteLine("Simulating View() operations...\n"); - - // View does the same query lookup, but then also iterates worksheets - dynamic? foundQuery = null; - try - { - // Find query by name (same as View) - _output.WriteLine("Finding query by name 'ComplexConnectionOnly'..."); - for (int i = 1; i <= queries.Count; i++) - { - dynamic? q = null; - try - { - q = queries[i]; - string qName = q.Name?.ToString() ?? ""; - if (qName.Equals("ComplexConnectionOnly", StringComparison.OrdinalIgnoreCase)) - { - foundQuery = q; - q = null; // Don't release - _output.WriteLine($" Found query at index {i}"); - break; - } - } - finally - { - if (q != null) ComUtilities.Release(ref q!); - } - } - - if (foundQuery == null) - { - _output.WriteLine(" ERROR: Query not found!"); - } - else - { - // Read Formula (same as View) - _output.WriteLine("\nReading Formula property..."); - try - { - string mCode = foundQuery.Formula?.ToString() ?? ""; - _output.WriteLine($" SUCCESS: Formula length = {mCode.Length}"); - } - catch (COMException ex) - { - _output.WriteLine($" FAILED: 0x{ex.HResult:X8} - {ex.Message}"); - } - - // Now iterate worksheets to detect load configuration (this is what View does extra) - _output.WriteLine("\nIterating worksheets to detect load configuration..."); - worksheets = _workbook.Worksheets; - _output.WriteLine($" Worksheet count: {worksheets.Count}"); - - for (int ws = 1; ws <= worksheets.Count; ws++) - { - dynamic? worksheet = null; - dynamic? queryTables = null; - dynamic? listObjects = null; - - try - { - _output.WriteLine($"\n Worksheet {ws}:"); - worksheet = worksheets[ws]; - _output.WriteLine($" Name: {worksheet.Name}"); - - // Check QueryTables - _output.WriteLine($" Accessing QueryTables..."); - try - { - queryTables = worksheet.QueryTables; - _output.WriteLine($" SUCCESS: QueryTables.Count = {queryTables.Count}"); - - for (int qt = 1; qt <= queryTables.Count; qt++) - { - dynamic? qTable = null; - dynamic? wbConn = null; - dynamic? oledbConn = null; - try - { - _output.WriteLine($" QueryTable {qt}:"); - qTable = queryTables[qt]; - - _output.WriteLine($" Accessing WorkbookConnection..."); - wbConn = qTable.WorkbookConnection; - if (wbConn == null) - { - _output.WriteLine($" WorkbookConnection is null"); - continue; - } - _output.WriteLine($" SUCCESS: WorkbookConnection accessed"); - - _output.WriteLine($" Accessing OLEDBConnection..."); - oledbConn = wbConn.OLEDBConnection; - if (oledbConn == null) - { - _output.WriteLine($" OLEDBConnection is null"); - continue; - } - _output.WriteLine($" SUCCESS: OLEDBConnection accessed"); - - _output.WriteLine($" Accessing Connection string..."); - string connString = oledbConn.Connection?.ToString() ?? ""; - _output.WriteLine($" SUCCESS: Connection string length = {connString.Length}"); - } - catch (COMException ex) - { - _output.WriteLine($" FAILED: 0x{ex.HResult:X8} - {ex.Message}"); - } - finally - { - if (oledbConn != null) ComUtilities.Release(ref oledbConn!); - if (wbConn != null) ComUtilities.Release(ref wbConn!); - if (qTable != null) ComUtilities.Release(ref qTable!); - } - } - } - catch (COMException ex) - { - _output.WriteLine($" FAILED accessing QueryTables: 0x{ex.HResult:X8} - {ex.Message}"); - } - - // Check ListObjects - _output.WriteLine($" Accessing ListObjects..."); - try - { - listObjects = worksheet.ListObjects; - _output.WriteLine($" SUCCESS: ListObjects.Count = {listObjects.Count}"); - - for (int lo = 1; lo <= listObjects.Count; lo++) - { - dynamic? listObj = null; - dynamic? loQueryTable = null; - - try - { - _output.WriteLine($" ListObject {lo}:"); - listObj = listObjects[lo]; - - _output.WriteLine($" Accessing QueryTable property..."); - try - { - loQueryTable = listObj.QueryTable; - if (loQueryTable == null) - { - _output.WriteLine($" QueryTable is null (manual table)"); - } - else - { - _output.WriteLine($" SUCCESS: QueryTable accessed"); - } - } - catch (COMException ex) - { - _output.WriteLine($" EXPECTED: ListObject.QueryTable threw 0x{ex.HResult:X8}"); - _output.WriteLine($" (Normal for ListObjects without QueryTable)"); - } - } - finally - { - if (loQueryTable != null) ComUtilities.Release(ref loQueryTable!); - if (listObj != null) ComUtilities.Release(ref listObj!); - } - } - } - catch (COMException ex) - { - _output.WriteLine($" FAILED accessing ListObjects: 0x{ex.HResult:X8} - {ex.Message}"); - } - } - catch (COMException ex) - { - _output.WriteLine($" FAILED accessing worksheet: 0x{ex.HResult:X8} - {ex.Message}"); - } - finally - { - if (listObjects != null) ComUtilities.Release(ref listObjects!); - if (queryTables != null) ComUtilities.Release(ref queryTables!); - if (worksheet != null) ComUtilities.Release(ref worksheet!); - } - } - } - } - finally - { - if (foundQuery != null) ComUtilities.Release(ref foundQuery!); - } - - _output.WriteLine("\n--- SUMMARY ---"); - _output.WriteLine("If List-like operations succeeded but View-like failed,"); - _output.WriteLine("the issue is in the worksheet/QueryTable/ListObject iteration."); - _output.WriteLine("If both succeeded, the issue may be specific to the real workbook's state."); - } - finally - { - ComUtilities.Release(ref worksheets); - ComUtilities.Release(ref query); - ComUtilities.Release(ref queries); - } - - _output.WriteLine("=== SCENARIO 18 COMPLETE ===\n"); - } - - // ========================================================================= - // SCENARIO 19: Query with Dependencies (like bug report) - // ========================================================================= - // Bug Report mentions query referencing another query (fnEnsureColumn) - - [Fact] - public void Scenario19_QueryWithDependencies() - { - _output.WriteLine("=== SCENARIO 19: Query with Dependencies ==="); - _output.WriteLine("PURPOSE: Test View/Update on queries that reference other queries\n"); - - // Create a base query first - const string baseQuery = """ - let - Source = #table({"ID", "Name"}, {{1, "A"}, {2, "B"}, {3, "C"}}) - in - Source - """; - - // Create a dependent query that references the base - const string dependentQuery = """ - let - Source = BaseQuery, - AddValue = Table.AddColumn(Source, "Value", each [ID] * 10) - in - AddValue - """; - - dynamic? queries = null; - dynamic? baseQ = null; - dynamic? depQ = null; - - try - { - queries = _workbook.Queries; - - // Create base query - _output.WriteLine("--- Creating Base Query ---"); - baseQ = queries.Add("BaseQuery", baseQuery); - _output.WriteLine($"Base query created. Count: {queries.Count}"); - - // Create dependent query - _output.WriteLine("\n--- Creating Dependent Query ---"); - depQ = queries.Add("DependentQuery", dependentQuery); - _output.WriteLine($"Dependent query created. Count: {queries.Count}"); - - // Test accessing Formula on both - _output.WriteLine("\n--- Testing Formula Access ---"); - - ComUtilities.Release(ref baseQ); - ComUtilities.Release(ref depQ); - - for (int i = 1; i <= queries.Count; i++) - { - dynamic? q = null; - try - { - q = queries[i]; - string name = q.Name?.ToString() ?? ""; - _output.WriteLine($"\nQuery: {name}"); - - try - { - string formula = q.Formula?.ToString() ?? ""; - _output.WriteLine($" Formula access: SUCCESS ({formula.Length} chars)"); - } - catch (COMException ex) - { - _output.WriteLine($" Formula access: FAILED 0x{ex.HResult:X8}"); - } - - // Try to update the formula (like Update does) - _output.WriteLine($" Testing Formula assignment..."); - try - { - string currentFormula = q.Formula?.ToString() ?? ""; - // Just reassign the same formula - q.Formula = currentFormula; - _output.WriteLine($" Formula assignment: SUCCESS"); - } - catch (COMException ex) - { - _output.WriteLine($" Formula assignment: FAILED 0x{ex.HResult:X8}"); - _output.WriteLine($" Message: {ex.Message}"); - } - } - finally - { - if (q != null) ComUtilities.Release(ref q!); - } - } - } - finally - { - ComUtilities.Release(ref depQ); - ComUtilities.Release(ref baseQ); - ComUtilities.Release(ref queries); - } - - _output.WriteLine("\n=== SCENARIO 19 COMPLETE ===\n"); - } - - // ========================================================================= - // SCENARIO 20: ListObject.QueryTable Access on Non-External Data Tables - // ========================================================================= - // CRITICAL TEST: Does accessing QueryTable property on a ListObject - // created from regular data (not external connection) throw an exception? - - [Fact] - public void Scenario20_ListObjectQueryTableAccess_OnRegularTable() - { - _output.WriteLine("=== SCENARIO 20: ListObject.QueryTable Access on Regular Tables ==="); - _output.WriteLine("PURPOSE: Determine if accessing QueryTable on a non-query ListObject throws\n"); - - dynamic? sheets = null; - dynamic? sheet = null; - dynamic? range = null; - dynamic? listObjects = null; - dynamic? listObject = null; - - try - { - // Create a regular Excel table (not from external data) - sheets = _workbook.Worksheets; - sheet = sheets.Add(); - string sheetName = sheet.Name; - - _output.WriteLine($"Created test sheet: {sheetName}"); - - // Add some data - sheet.Range["A1"].Value2 = "Header1"; - sheet.Range["B1"].Value2 = "Header2"; - sheet.Range["A2"].Value2 = "Value1"; - sheet.Range["B2"].Value2 = "Value2"; - - // Create a ListObject from range (regular table, NOT from external data) - range = sheet.Range["A1:B2"]; - listObjects = sheet.ListObjects; - - // xlSrcRange = 1 (create from range data) - listObject = listObjects.Add(1, range, Type.Missing, 1, Type.Missing); - string tableName = listObject.Name; - _output.WriteLine($"Created regular table: {tableName}"); - - // NOW: Try to access QueryTable on this regular table - _output.WriteLine("\n--- Testing ListObject.QueryTable access ---"); - _output.WriteLine("Attempting to access listObject.QueryTable on regular table..."); - - dynamic? queryTable = null; - try - { - queryTable = listObject.QueryTable; - if (queryTable == null) - { - _output.WriteLine("RESULT: QueryTable property returned NULL (no exception)"); - } - else - { - _output.WriteLine($"RESULT: QueryTable property returned an object (unexpected for regular table)"); - } - } - catch (System.Runtime.InteropServices.COMException ex) - { - _output.WriteLine($"RESULT: COMException thrown!"); - _output.WriteLine($" HResult: 0x{ex.HResult:X8}"); - _output.WriteLine($" Message: {ex.Message}"); - _output.WriteLine("\n*** FINDING: View/Update MUST use try-catch when accessing ListObject.QueryTable ***"); - } - finally - { - if (queryTable != null) ComUtilities.Release(ref queryTable!); - } - - // Clean up the test table - listObject.Delete(); - sheet.Delete(); - - _output.WriteLine("\nCleanup complete"); - } - finally - { - if (listObject != null) ComUtilities.Release(ref listObject!); - if (listObjects != null) ComUtilities.Release(ref listObjects!); - if (range != null) ComUtilities.Release(ref range!); - if (sheet != null) ComUtilities.Release(ref sheet!); - if (sheets != null) ComUtilities.Release(ref sheets!); - } - - _output.WriteLine("\n=== SCENARIO 20 COMPLETE ===\n"); - } - - /// - /// Gets the column count from the first table. - /// - private int GetFirstTableColumnCount() - { - dynamic? sheet = null; - dynamic? listObjects = null; - dynamic? listObject = null; - dynamic? columns = null; - - try - { - sheet = _workbook.Worksheets[1]; - listObjects = sheet.ListObjects; - - if (listObjects.Count == 0) - { - return 0; - } - - listObject = listObjects[1]; - columns = listObject.ListColumns; - return columns.Count; - } - finally - { - ComUtilities.Release(ref columns); - ComUtilities.Release(ref listObject); - ComUtilities.Release(ref listObjects); - ComUtilities.Release(ref sheet); - } - } -} - - - - diff --git a/tests/ExcelMcp.McpServer.Tests/Integration/CoreCommandsCoverageTests.cs b/tests/ExcelMcp.McpServer.Tests/Integration/CoreCommandsCoverageTests.cs deleted file mode 100644 index f6045986..00000000 --- a/tests/ExcelMcp.McpServer.Tests/Integration/CoreCommandsCoverageTests.cs +++ /dev/null @@ -1,410 +0,0 @@ -// Suppress IDE0005 (unnecessary using) – explicit usings kept for clarity in test reflection code -#pragma warning disable IDE0005 -using System.Reflection; -#pragma warning restore IDE0005 -using Sbroenne.ExcelMcp.Core.Commands; -using Sbroenne.ExcelMcp.Core.Commands.Chart; -using Sbroenne.ExcelMcp.Core.Commands.PivotTable; -using Sbroenne.ExcelMcp.Core.Commands.Range; -using Sbroenne.ExcelMcp.Core.Commands.Slicer; -using Sbroenne.ExcelMcp.Core.Commands.Table; -using Sbroenne.ExcelMcp.Generated; -using Xunit; - -namespace Sbroenne.ExcelMcp.McpServer.Tests.Integration; - -/// -/// CRITICAL: Automated verification that all Core Commands methods are exposed via MCP actions. -/// These tests PREVENT regression by ensuring compile-time and runtime coverage. -/// -/// If these tests fail, it means: -/// 1. A new Core method was added but no MCP action was created -/// 2. An enum value is missing from ToolActions.cs -/// 3. A ToActionString mapping is missing from ActionExtensions.cs -/// -/// DO NOT disable or skip these tests without fixing the underlying coverage gap! -/// -public class CoreCommandsCoverageTests -{ - /// - /// Verifies IPowerQueryCommands has matching PowerQueryAction enum values - /// - [Fact] - public void IPowerQueryCommands_AllMethodsHaveEnumValues() - { - var coreMethodCount = GetAsyncMethodCount(typeof(IPowerQueryCommands)); - var enumValueCount = Enum.GetValues().Length; - - Assert.True( - enumValueCount >= coreMethodCount, - $"IPowerQueryCommands has {coreMethodCount} methods but PowerQueryAction has only {enumValueCount} enum values. " + - $"Add missing enum values to ToolActions.cs!"); - } - - /// - /// Verifies ISheetCommands has matching SheetAction enum values - /// - [Fact] - public void ISheetCommands_AllMethodsHaveEnumValues() - { - var coreMethodCount = GetAsyncMethodCount(typeof(ISheetCommands)); - var enumValueCount = Enum.GetValues().Length; - - Assert.True( - enumValueCount >= coreMethodCount, - $"ISheetCommands has {coreMethodCount} methods but SheetAction has only {enumValueCount} enum values. " + - $"Add missing enum values to interface or regenerate!"); - } - - /// - /// Verifies IRangeCommands has matching RangeAction enum values - /// - [Fact] - public void IRangeCommands_AllMethodsHaveEnumValues() - { - var coreMethodCount = GetAsyncMethodCount(typeof(IRangeCommands)); - var enumValueCount = Enum.GetValues().Length; - - Assert.True( - enumValueCount >= coreMethodCount, - $"IRangeCommands has {coreMethodCount} methods but RangeAction has only {enumValueCount} enum values. " + - $"Add missing enum values to ToolActions.cs!"); - } - - /// - /// Verifies ITableCommands has matching TableAction enum values - /// - [Fact] - public void ITableCommands_AllMethodsHaveEnumValues() - { - var coreMethodCount = GetAsyncMethodCount(typeof(ITableCommands)); - var enumValueCount = Enum.GetValues().Length; - - Assert.True( - enumValueCount >= coreMethodCount, - $"ITableCommands has {coreMethodCount} methods but TableAction has only {enumValueCount} enum values. " + - $"Add missing enum values to ToolActions.cs!"); - } - - /// - /// Verifies IConnectionCommands has matching ConnectionAction enum values - /// - [Fact] - public void IConnectionCommands_AllMethodsHaveEnumValues() - { - var coreMethodCount = GetAsyncMethodCount(typeof(IConnectionCommands)); - var enumValueCount = Enum.GetValues().Length; - - Assert.True( - enumValueCount >= coreMethodCount, - $"IConnectionCommands has {coreMethodCount} methods but ConnectionAction has only {enumValueCount} enum values. " + - $"Add missing enum values to ToolActions.cs!"); - } - - /// - /// Verifies IDataModelCommands has matching DataModelAction enum values - /// - [Fact] - public void IDataModelCommands_AllMethodsHaveEnumValues() - { - var coreMethodCount = GetAsyncMethodCount(typeof(IDataModelCommands)); - var enumValueCount = Enum.GetValues().Length; - - Assert.True( - enumValueCount >= coreMethodCount, - $"IDataModelCommands has {coreMethodCount} methods but DataModelAction has only {enumValueCount} enum values. " + - $"Add missing enum values to ToolActions.cs!"); - } - - /// - /// Verifies IPivotTableCommands has matching PivotTableAction enum values - /// - [Fact] - public void IPivotTableCommands_AllMethodsHaveEnumValues() - { - var coreMethodCount = GetAsyncMethodCount(typeof(IPivotTableCommands)); - var enumValueCount = Enum.GetValues().Length; - - Assert.True( - enumValueCount >= coreMethodCount, - $"IPivotTableCommands has {coreMethodCount} methods but PivotTableAction has only {enumValueCount} enum values. " + - $"Add missing enum values to interface or regenerate!"); - } - - /// - /// Verifies IPivotTableFieldCommands has matching PivotTableFieldAction enum values - /// - [Fact] - public void IPivotTableFieldCommands_AllMethodsHaveEnumValues() - { - var coreMethodCount = GetAsyncMethodCount(typeof(IPivotTableFieldCommands)); - var enumValueCount = Enum.GetValues().Length; - - Assert.True( - enumValueCount >= coreMethodCount, - $"IPivotTableFieldCommands has {coreMethodCount} methods but PivotTableFieldAction has only {enumValueCount} enum values. " + - $"Add missing enum values to interface or regenerate!"); - } - - /// - /// Verifies IPivotTableCalcCommands has matching PivotTableCalcAction enum values - /// - [Fact] - public void IPivotTableCalcCommands_AllMethodsHaveEnumValues() - { - var coreMethodCount = GetAsyncMethodCount(typeof(IPivotTableCalcCommands)); - var enumValueCount = Enum.GetValues().Length; - - Assert.True( - enumValueCount >= coreMethodCount, - $"IPivotTableCalcCommands has {coreMethodCount} methods but PivotTableCalcAction has only {enumValueCount} enum values. " + - $"Add missing enum values to interface or regenerate!"); - } - - /// - /// Verifies ISlicerCommands has matching SlicerAction enum values - /// - [Fact] - public void ISlicerCommands_AllMethodsHaveEnumValues() - { - var coreMethodCount = GetAsyncMethodCount(typeof(ISlicerCommands)); - var enumValueCount = Enum.GetValues().Length; - - Assert.True( - enumValueCount >= coreMethodCount, - $"ISlicerCommands has {coreMethodCount} methods but SlicerAction has only {enumValueCount} enum values. " + - $"Add missing enum values to interface or regenerate!"); - } - - /// - /// Verifies IChartCommands has matching ChartAction enum values - /// - [Fact] - public void IChartCommands_AllMethodsHaveEnumValues() - { - var coreMethodCount = GetAsyncMethodCount(typeof(IChartCommands)); - var enumValueCount = Enum.GetValues().Length; - - Assert.True( - enumValueCount >= coreMethodCount, - $"IChartCommands has {coreMethodCount} methods but ChartAction has only {enumValueCount} enum values. " + - $"Add missing enum values or regenerate!"); - } - - /// - /// Verifies IChartConfigCommands has matching ChartConfigAction enum values - /// - [Fact] - public void IChartConfigCommands_AllMethodsHaveEnumValues() - { - var coreMethodCount = GetAsyncMethodCount(typeof(IChartConfigCommands)); - var enumValueCount = Enum.GetValues().Length; - - Assert.True( - enumValueCount >= coreMethodCount, - $"IChartConfigCommands has {coreMethodCount} methods but ChartConfigAction has only {enumValueCount} enum values. " + - $"Add missing enum values or regenerate!"); - } - - /// - /// Verifies all PowerQueryAction enum values have ToActionString mappings - /// - [Fact] - public void PowerQueryAction_AllEnumValuesHaveMappings() - { - foreach (var action in Enum.GetValues()) - { - var exception = Record.Exception(() => ServiceRegistry.PowerQuery.ToActionString(action)); - Assert.Null(exception); - Assert.NotEmpty(ServiceRegistry.PowerQuery.ToActionString(action)); - } - } - - /// - /// Verifies all SheetAction enum values have ToActionString mappings (via generated ServiceRegistry) - /// - [Fact] - public void SheetAction_AllEnumValuesHaveMappings() - { - foreach (var action in Enum.GetValues()) - { - var exception = Record.Exception(() => ServiceRegistry.Sheet.ToActionString(action)); - Assert.Null(exception); - Assert.NotEmpty(ServiceRegistry.Sheet.ToActionString(action)); - } - } - - /// - /// Verifies all SheetStyleAction enum values have ToActionString mappings (via generated ServiceRegistry) - /// - [Fact] - public void SheetStyleAction_AllEnumValuesHaveMappings() - { - foreach (var action in Enum.GetValues()) - { - var exception = Record.Exception(() => ServiceRegistry.SheetStyle.ToActionString(action)); - Assert.Null(exception); - Assert.NotEmpty(ServiceRegistry.SheetStyle.ToActionString(action)); - } - } - - /// - /// Verifies all RangeAction enum values have ToActionString mappings - /// - [Fact] - public void RangeAction_AllEnumValuesHaveMappings() - { - foreach (var action in Enum.GetValues()) - { - var exception = Record.Exception(() => ServiceRegistry.Range.ToActionString(action)); - Assert.Null(exception); - Assert.NotEmpty(ServiceRegistry.Range.ToActionString(action)); - } - } - - /// - /// Verifies all TableAction enum values have ToActionString mappings - /// - [Fact] - public void TableAction_AllEnumValuesHaveMappings() - { - foreach (var action in Enum.GetValues()) - { - var exception = Record.Exception(() => ServiceRegistry.Table.ToActionString(action)); - Assert.Null(exception); - Assert.NotEmpty(ServiceRegistry.Table.ToActionString(action)); - } - } - - /// - /// Verifies all ConnectionAction enum values have ToActionString mappings - /// - [Fact] - public void ConnectionAction_AllEnumValuesHaveMappings() - { - foreach (var action in Enum.GetValues()) - { - var exception = Record.Exception(() => ServiceRegistry.Connection.ToActionString(action)); - Assert.Null(exception); - Assert.NotEmpty(ServiceRegistry.Connection.ToActionString(action)); - } - } - - /// - /// Verifies all DataModelAction enum values have ToActionString mappings - /// - [Fact] - public void DataModelAction_AllEnumValuesHaveMappings() - { - foreach (var action in Enum.GetValues()) - { - var exception = Record.Exception(() => ServiceRegistry.DataModel.ToActionString(action)); - Assert.Null(exception); - Assert.NotEmpty(ServiceRegistry.DataModel.ToActionString(action)); - } - } - - /// - /// Verifies all PivotTableAction enum values have ToActionString mappings - /// - [Fact] - public void PivotTableAction_AllEnumValuesHaveMappings() - { - foreach (var action in Enum.GetValues()) - { - var exception = Record.Exception(() => ServiceRegistry.PivotTable.ToActionString(action)); - Assert.Null(exception); - Assert.NotEmpty(ServiceRegistry.PivotTable.ToActionString(action)); - } - } - - /// - /// Verifies all PivotTableFieldAction enum values have ToActionString mappings - /// - [Fact] - public void PivotTableFieldAction_AllEnumValuesHaveMappings() - { - foreach (var action in Enum.GetValues()) - { - var exception = Record.Exception(() => ServiceRegistry.PivotTableField.ToActionString(action)); - Assert.Null(exception); - Assert.NotEmpty(ServiceRegistry.PivotTableField.ToActionString(action)); - } - } - - /// - /// Verifies all PivotTableCalcAction enum values have ToActionString mappings - /// - [Fact] - public void PivotTableCalcAction_AllEnumValuesHaveMappings() - { - foreach (var action in Enum.GetValues()) - { - var exception = Record.Exception(() => ServiceRegistry.PivotTableCalc.ToActionString(action)); - Assert.Null(exception); - Assert.NotEmpty(ServiceRegistry.PivotTableCalc.ToActionString(action)); - } - } - - /// - /// Verifies all ChartAction enum values have ToActionString mappings (via generated ServiceRegistry) - /// - [Fact] - public void ChartAction_AllEnumValuesHaveMappings() - { - foreach (var action in Enum.GetValues()) - { - var exception = Record.Exception(() => ServiceRegistry.Chart.ToActionString(action)); - Assert.Null(exception); - Assert.NotEmpty(ServiceRegistry.Chart.ToActionString(action)); - } - } - - /// - /// Verifies all ChartConfigAction enum values have ToActionString mappings (via generated ServiceRegistry) - /// - [Fact] - public void ChartConfigAction_AllEnumValuesHaveMappings() - { - foreach (var action in Enum.GetValues()) - { - var exception = Record.Exception(() => ServiceRegistry.ChartConfig.ToActionString(action)); - Assert.Null(exception); - Assert.NotEmpty(ServiceRegistry.ChartConfig.ToActionString(action)); - } - } - - /// - /// Verifies all SlicerAction enum values have ToActionString mappings - /// - [Fact] - public void SlicerAction_AllEnumValuesHaveMappings() - { - foreach (var action in Enum.GetValues()) - { - var exception = Record.Exception(() => ServiceRegistry.Slicer.ToActionString(action)); - Assert.Null(exception); - Assert.NotEmpty(ServiceRegistry.Slicer.ToActionString(action)); - } - } - - /// - /// Helper: Counts public async methods in an interface (excludes properties, events, etc.) - /// - private static int GetAsyncMethodCount(Type interfaceType) - { - // Count DISTINCT async method base names (treat overloads as single logical operation). - // Reason: Enum actions represent semantic operations, not overload variants. - // Example: Refresh(...) and Refresh(..., TimeSpan?) map to single "refresh" action. - return interfaceType - .GetMethods(BindingFlags.Public | BindingFlags.Instance) - .Where(m => m.Name.EndsWith("Async", StringComparison.Ordinal)) - .Select(m => m.Name) // includes overload name twice - .Distinct(StringComparer.Ordinal) // collapse overloads - .Count(); - } -} - - - - diff --git a/tests/ExcelMcp.McpServer.Tests/Integration/Tools/McpServerSmokeTests.cs b/tests/ExcelMcp.McpServer.Tests/Integration/Tools/McpServerSmokeTests.cs deleted file mode 100644 index 721e7391..00000000 --- a/tests/ExcelMcp.McpServer.Tests/Integration/Tools/McpServerSmokeTests.cs +++ /dev/null @@ -1,729 +0,0 @@ -// Copyright (c) Sbroenne. All rights reserved. -// Licensed under the MIT License. - -using System.IO.Pipelines; -using System.Text.Json; -using ModelContextProtocol.Client; -using ModelContextProtocol.Protocol; -using Sbroenne.ExcelMcp.ComInterop.Session; -using Sbroenne.ExcelMcp.McpServer.Telemetry; -using Xunit; -using Xunit.Abstractions; - -namespace Sbroenne.ExcelMcp.McpServer.Tests.Integration.Tools; - -/// -/// End-to-end smoke tests for the MCP Server using the official MCP SDK client. -/// -/// PURPOSE: Validates the complete MCP protocol stack works correctly with real Excel operations. -/// PATTERN: Uses Program.ConfigureTestTransport() to inject in-memory pipes, then runs the real server. -/// RUNTIME: ~30-60 seconds (requires Excel COM automation). -/// -/// These tests exercise: -/// - Full DI pipeline (exact same as production) -/// - MCP protocol serialization/deserialization -/// - Tool discovery and invocation via MCP protocol -/// - Real Excel operations through COM interop -/// - Session management across multiple tool calls -/// - Application Insights telemetry (same configuration as production) -/// -/// The server is a BLACK BOX - tests only interact via MCP protocol. -/// Only the transport differs: pipes instead of stdio. -/// -/// Run before commits to catch breaking changes: -/// dotnet test --filter "FullyQualifiedName~McpServerSmokeTests" -/// -[Collection("ProgramTransport")] // Uses Program.ConfigureTestTransport() - must run sequentially -[Trait("Category", "Integration")] -[Trait("Speed", "Medium")] -[Trait("Layer", "McpServer")] -[Trait("Feature", "SmokeTest")] -[Trait("RequiresExcel", "true")] -public class McpServerSmokeTests : IAsyncLifetime, IAsyncDisposable -{ - private readonly ITestOutputHelper _output; - private readonly string _tempDir; - private readonly string _testExcelFile; - private readonly string _testCsvFile; - - // MCP transport pipes - private readonly Pipe _clientToServerPipe = new(); - private readonly Pipe _serverToClientPipe = new(); - private readonly CancellationTokenSource _cts = new(); - private McpClient? _client; - private Task? _serverTask; - - public McpServerSmokeTests(ITestOutputHelper output) - { - _output = output; - - // Create temp directory for test files - _tempDir = Path.Join(Path.GetTempPath(), $"McpSmokeTest_{Guid.NewGuid():N}"); - Directory.CreateDirectory(_tempDir); - - _testExcelFile = Path.Join(_tempDir, "SmokeTest.xlsx"); - _testCsvFile = Path.Join(_tempDir, "SampleData.csv"); - - _output.WriteLine($"Test directory: {_tempDir}"); - } - - /// - /// Setup: Configure test transport and run the real MCP server. - /// The server is a BLACK BOX - we only configure transport, everything else is production code. - /// - public async Task InitializeAsync() - { - // Configure the server to use our test pipes instead of stdio - // This is the ONLY difference from production - transport layer only - Program.ConfigureTestTransport(_clientToServerPipe, _serverToClientPipe); - - // Run the REAL server (Program.Main) - exact same code path as production - // The server will use our configured pipes for transport - _serverTask = Program.Main([]); - - // Allow server to initialize before client connection - // SDK 0.5.0+ has stricter initialization timing - await Task.Delay(100); - - // Create client connected to the server via pipes - _client = await McpClient.CreateAsync( - new StreamClientTransport( - serverInput: _clientToServerPipe.Writer.AsStream(), - serverOutput: _serverToClientPipe.Reader.AsStream()), - clientOptions: new McpClientOptions - { - ClientInfo = new() { Name = "SmokeTestClient", Version = "1.0.0" }, - InitializationTimeout = TimeSpan.FromSeconds(30) // Increase timeout for test stability - }, - cancellationToken: _cts.Token); - - _output.WriteLine($"✓ Connected to server: {_client.ServerInfo?.Name} v{_client.ServerInfo?.Version}"); - } - - public async Task DisposeAsync() - { - await DisposeAsyncCore(); - } - - async ValueTask IAsyncDisposable.DisposeAsync() - { - await DisposeAsyncCore(); - GC.SuppressFinalize(this); - } - - private async Task DisposeAsyncCore() - { - // Flush telemetry before shutdown to ensure test telemetry is sent - ExcelMcpTelemetry.Flush(); - - // Dispose client first - this signals we're done sending requests - if (_client != null) - { - await _client.DisposeAsync(); - } - - // Complete the pipes to signal EOF - this triggers GRACEFUL server shutdown - // The MCP SDK will see EOF and stop the host naturally, allowing - // Application Insights and other services to flush during shutdown - _clientToServerPipe.Writer.Complete(); - _serverToClientPipe.Writer.Complete(); - - // Wait for server to shut down gracefully (with timeout) - if (_serverTask != null) - { - // Give the server time to flush telemetry and clean up - var shutdownTimeout = Task.Delay(TimeSpan.FromSeconds(10)); - var completed = await Task.WhenAny(_serverTask, shutdownTimeout); - - if (completed == shutdownTimeout) - { - // Server didn't shut down in time - cancel as fallback - await _cts.CancelAsync(); - try - { - await _serverTask; - } - catch (OperationCanceledException) - { - // Expected when we had to force cancel - } - } - } - - // Reset test transport for next test - Program.ResetTestTransport(); - - _cts.Dispose(); - - // Clean up temp files - if (Directory.Exists(_tempDir)) - { - try - { - Directory.Delete(_tempDir, recursive: true); - } - catch - { - // Ignore cleanup errors - } - } - } - - /// - /// Comprehensive smoke test that exercises all 12 MCP tools via the SDK client. - /// This validates the complete E2E flow: MCP protocol → DI → Tool → Core → Excel COM. - /// - [Fact] - public async Task SmokeTest_AllTools_E2EWorkflow() - { - _output.WriteLine("=== MCP SERVER E2E SMOKE TEST (SDK CLIENT) ==="); - _output.WriteLine("Testing all 25 tools via MCP protocol with real Excel...\n"); - - // ===================================================================== - // STEP 1: CREATE AND OPEN SESSION - // ===================================================================== - _output.WriteLine("✓ Step 1: Creating workbook and opening session via MCP protocol..."); - - var createResult = await CallToolAsync("file", new Dictionary - { - ["action"] = "create", - ["path"] = _testExcelFile - }); - AssertSuccess(createResult, "File creation and session open"); - Assert.True(File.Exists(_testExcelFile), "Excel file should exist"); - var sessionId = GetJsonProperty(createResult, "session_id"); - Assert.NotNull(sessionId); - _output.WriteLine($" ✓ file: Create passed (session: {sessionId})"); - - // ===================================================================== - // STEP 3: WORKSHEET OPERATIONS - // ===================================================================== - _output.WriteLine("\n✓ Step 3: Worksheet operations..."); - - var listSheetsResult = await CallToolAsync("worksheet", new Dictionary - { - ["action"] = "list", - ["session_id"] = sessionId - }); - AssertSuccess(listSheetsResult, "List worksheets"); - - var createSheetResult = await CallToolAsync("worksheet", new Dictionary - { - ["action"] = "create", - ["session_id"] = sessionId, - ["sheet_name"] = "Data" - }); - AssertSuccess(createSheetResult, "Create worksheet"); - _output.WriteLine(" ✓ worksheet: List and Create passed"); - - // ===================================================================== - // STEP 4: RANGE OPERATIONS - // ===================================================================== - _output.WriteLine("\n✓ Step 4: Range operations..."); - - var values = new List> - { - new() { "Name", "Value", "Date" }, - new() { "Item1", 100, "2024-01-01" }, - new() { "Item2", 200, "2024-01-02" } - }; - - var setValuesResult = await CallToolAsync("range", new Dictionary - { - ["action"] = "set-values", - ["path"] = _testExcelFile, - ["session_id"] = sessionId, - ["sheet_name"] = "Data", - ["range_address"] = "A1:C3", - ["values"] = values - }); - AssertSuccess(setValuesResult, "Set values"); - - var getValuesResult = await CallToolAsync("range", new Dictionary - { - ["action"] = "get-values", - ["path"] = _testExcelFile, - ["session_id"] = sessionId, - ["sheet_name"] = "Data", - ["range_address"] = "A1:C3" - }); - AssertSuccess(getValuesResult, "Get values"); - _output.WriteLine(" ✓ range: SetValues and GetValues passed"); - - // ===================================================================== - // STEP 5: TABLE OPERATIONS - // ===================================================================== - _output.WriteLine("\n✓ Step 5: Table operations..."); - - var createTableResult = await CallToolAsync("table", new Dictionary - { - ["action"] = "create", - ["path"] = _testExcelFile, - ["session_id"] = sessionId, - ["table_name"] = "DataTable", - ["sheet_name"] = "Data", - ["range_address"] = "A1:C3", - ["has_headers"] = true - }); - AssertSuccess(createTableResult, "Create table"); - - var listTablesResult = await CallToolAsync("table", new Dictionary - { - ["action"] = "list", - ["path"] = _testExcelFile, - ["session_id"] = sessionId - }); - AssertSuccess(listTablesResult, "List tables"); - _output.WriteLine(" ✓ table: Create and List passed"); - - // ===================================================================== - // STEP 6: NAMED RANGE OPERATIONS - // ===================================================================== - _output.WriteLine("\n✓ Step 6: Named range operations..."); - - var createParamResult = await CallToolAsync("namedrange", new Dictionary - { - ["action"] = "create", - ["path"] = _testExcelFile, - ["session_id"] = sessionId, - ["name"] = "ReportDate", - ["reference"] = "=Data!$C$2" - }); - AssertSuccess(createParamResult, "Create named range"); - - var readParamResult = await CallToolAsync("namedrange", new Dictionary - { - ["action"] = "read", - ["path"] = _testExcelFile, - ["session_id"] = sessionId, - ["name"] = "ReportDate" - }); - AssertSuccess(readParamResult, "Read named range"); - _output.WriteLine(" ✓ namedrange: Create and Read passed"); - - // ===================================================================== - // STEP 7: POWER QUERY OPERATIONS - // ===================================================================== - _output.WriteLine("\n✓ Step 7: Power Query operations..."); - - // Create test CSV - var csvContent = "Product,Quantity\nWidget,10\nGadget,20"; - await File.WriteAllTextAsync(_testCsvFile, csvContent); - - var mCode = $@"let - Source = Csv.Document(File.Contents(""{_testCsvFile.Replace("\\", "\\\\")}""),[Delimiter="","", Columns=2, Encoding=1252, QuoteStyle=QuoteStyle.None]), - PromotedHeaders = Table.PromoteHeaders(Source, [PromoteAllScalars=true]) -in - PromotedHeaders"; - - var createQueryResult = await CallToolAsync("powerquery", new Dictionary - { - ["action"] = "create", - ["session_id"] = sessionId, - ["query_name"] = "CsvData", - ["m_code"] = mCode, - ["load_destination"] = "connection-only" - }); - AssertSuccess(createQueryResult, "Create Power Query"); - - var listQueriesResult = await CallToolAsync("powerquery", new Dictionary - { - ["action"] = "list", - ["session_id"] = sessionId - }); - AssertSuccess(listQueriesResult, "List Power Queries"); - - // Rename the query (US1: Power Query rename) - var renameQueryResult = await CallToolAsync("powerquery", new Dictionary - { - ["action"] = "rename", - ["session_id"] = sessionId, - ["old_name"] = "CsvData", - ["new_name"] = "ProductData" - }); - AssertSuccess(renameQueryResult, "Rename Power Query"); - Assert.Contains("ProductData", renameQueryResult); - - // Verify rename by listing again - var listAfterRenameResult = await CallToolAsync("powerquery", new Dictionary - { - ["action"] = "list", - ["session_id"] = sessionId - }); - AssertSuccess(listAfterRenameResult, "List Power Queries after rename"); - Assert.Contains("ProductData", listAfterRenameResult); - Assert.DoesNotContain("CsvData", listAfterRenameResult); - - _output.WriteLine(" ✓ powerquery: Create, List, and Rename passed"); - - // ===================================================================== - // STEP 8: CONNECTION OPERATIONS - // ===================================================================== - _output.WriteLine("\n✓ Step 8: Connection operations..."); - - var listConnectionsResult = await CallToolAsync("connection", new Dictionary - { - ["action"] = "list", - ["path"] = _testExcelFile, - ["session_id"] = sessionId - }); - AssertSuccess(listConnectionsResult, "List connections"); - _output.WriteLine(" ✓ connection: List passed"); - - // ===================================================================== - // STEP 9: PIVOTTABLE OPERATIONS - // ===================================================================== - _output.WriteLine("\n✓ Step 9: PivotTable operations..."); - - var createPivotResult = await CallToolAsync("pivottable", new Dictionary - { - ["action"] = "create-from-table", - ["session_id"] = sessionId, - ["table_name"] = "DataTable", - ["destination_sheet"] = "Data", - ["destination_cell"] = "E1", - ["pivot_table_name"] = "SalesPivot" - }); - AssertSuccess(createPivotResult, "Create PivotTable"); - - var listPivotsResult = await CallToolAsync("pivottable", new Dictionary - { - ["action"] = "list", - ["session_id"] = sessionId - }); - AssertSuccess(listPivotsResult, "List PivotTables"); - _output.WriteLine(" ✓ pivottable: Create and List passed"); - - // ===================================================================== - // STEP 10: CHART OPERATIONS - // ===================================================================== - _output.WriteLine("\n✓ Step 10: Chart operations..."); - - var createChartResult = await CallToolAsync("chart", new Dictionary - { - ["action"] = "create-from-range", - ["session_id"] = sessionId, - ["sheet_name"] = "Data", - ["source_range_address"] = "A1:C3", - ["chart_type"] = "ColumnClustered", - ["left"] = 50, - ["top"] = 50, - ["width"] = 400, - ["height"] = 300, - ["chart_name"] = "DataChart" - }); - AssertSuccess(createChartResult, "Create Chart"); - - var listChartsResult = await CallToolAsync("chart", new Dictionary - { - ["action"] = "list", - ["session_id"] = sessionId - }); - // Chart List returns array directly - Assert.NotNull(listChartsResult); - _output.WriteLine(" ✓ chart: Create and List passed"); - - // ===================================================================== - // STEP 11: DATA MODEL OPERATIONS - // ===================================================================== - _output.WriteLine("\n✓ Step 11: Data Model operations..."); - - var listDataModelResult = await CallToolAsync("datamodel", new Dictionary - { - ["action"] = "list-tables", - ["session_id"] = sessionId - }); - AssertSuccess(listDataModelResult, "List Data Model tables"); - - // Test rename-table returns expected failure due to Excel limitation (not a crash) - // First, we need a PQ-backed table in the Data Model - // The ProductData query was created above - load it to Data Model - var loadToDmResult = await CallToolAsync("powerquery", new Dictionary - { - ["action"] = "load-to", - ["session_id"] = sessionId, - ["query_name"] = "ProductData", - ["load_destination"] = "load-to-data-model" - }); - AssertSuccess(loadToDmResult, "Load Power Query to Data Model"); - - // Verify table exists - var listAfterLoadResult = await CallToolAsync("datamodel", new Dictionary - { - ["action"] = "list-tables", - ["session_id"] = sessionId - }); - AssertSuccess(listAfterLoadResult, "List Data Model tables after load"); - Assert.Contains("ProductData", listAfterLoadResult); - - // Attempt rename-table - this will return success=false due to Excel limitation - var renameTableResult = await CallToolAsync("datamodel", new Dictionary - { - ["action"] = "rename-table", - ["session_id"] = sessionId, - ["old_name"] = "ProductData", - ["new_name"] = "RenamedProductData" - }); - // Expect JSON with success=false (not a crash) - var renameJson = JsonDocument.Parse(renameTableResult); - Assert.True(renameJson.RootElement.TryGetProperty("success", out var renameSuccess)); - Assert.False(renameSuccess.GetBoolean(), "Rename-table should fail due to Excel limitation"); - Assert.True(renameJson.RootElement.TryGetProperty("errorMessage", out var renameError)); - var renameErrorText = renameError.GetString() ?? ""; - // Error could be "immutable", "cannot be renamed", or "not found" (Power Query issue) - Assert.True( - renameErrorText.Contains("immutable", StringComparison.OrdinalIgnoreCase) || - renameErrorText.Contains("cannot be renamed", StringComparison.OrdinalIgnoreCase) || - renameErrorText.Contains("not found", StringComparison.OrdinalIgnoreCase), - $"Expected error about rename limitation but got: {renameErrorText}"); - _output.WriteLine(" ✓ datamodel: RenameTable correctly returns error (Excel limitation)"); - - _output.WriteLine(" ✓ datamodel: ListTables passed"); - - // ===================================================================== - // STEP 12: CONDITIONAL FORMAT OPERATIONS - // ===================================================================== - _output.WriteLine("\n✓ Step 12: Conditional Format operations..."); - - var addRuleResult = await CallToolAsync("conditionalformat", new Dictionary - { - ["action"] = "add-rule", - ["path"] = _testExcelFile, - ["session_id"] = sessionId, - ["sheet_name"] = "Data", - ["range_address"] = "B2:B3", - ["rule_type"] = "cellvalue", // Note: no hyphen - Core expects "cellvalue" not "cell-value" - ["operator_type"] = "greater", - ["formula1"] = "100", - ["interior_color"] = "#00FF00" - }); - AssertSuccess(addRuleResult, "Add conditional format rule"); - _output.WriteLine(" ✓ conditionalformat: AddRule passed"); - - // ===================================================================== - // STEP 13: VBA OPERATIONS - // ===================================================================== - _output.WriteLine("\n✓ Step 13: VBA operations..."); - - var listVbaResult = await CallToolAsync("vba", new Dictionary - { - ["action"] = "list", - ["path"] = _testExcelFile, - ["session_id"] = sessionId - }); - AssertSuccess(listVbaResult, "List VBA modules"); - _output.WriteLine(" ✓ vba: List passed"); - - // ===================================================================== - // STEP 14: CLOSE SESSION (save changes) - // ===================================================================== - _output.WriteLine("\n✓ Step 14: Closing session (saving changes)..."); - - var closeResult = await CallToolAsync("file", new Dictionary - { - ["action"] = "close", - ["session_id"] = sessionId, - ["save"] = true - }); - AssertSuccess(closeResult, "Close session"); - _output.WriteLine(" ✓ Session saved and closed"); - - // ===================================================================== - // STEP 15: VERIFY PERSISTENCE - // ===================================================================== - _output.WriteLine("\n✓ Step 15: Verifying persistence..."); - - var verifyOpenResult = await CallToolAsync("file", new Dictionary - { - ["action"] = "open", - ["path"] = _testExcelFile - }); - AssertSuccess(verifyOpenResult, "Re-open for verification"); - var verifySessionId = GetJsonProperty(verifyOpenResult, "session_id"); - - try - { - var finalSheetsResult = await CallToolAsync("worksheet", new Dictionary - { - ["action"] = "list", - ["session_id"] = verifySessionId - }); - AssertSuccess(finalSheetsResult, "Final worksheet list"); - - // Verify Data sheet exists - Assert.Contains("Data", finalSheetsResult); - _output.WriteLine(" ✓ All changes persisted correctly"); - } - finally - { - await CallToolAsync("file", new Dictionary - { - ["action"] = "close", - ["session_id"] = verifySessionId, - ["save"] = false - }); - } - - // ===================================================================== - // FINAL SUMMARY - // ===================================================================== - _output.WriteLine("\n=== E2E SMOKE TEST COMPLETE ==="); - _output.WriteLine("✅ All 12 MCP tools tested via SDK client"); - _output.WriteLine("✅ Full MCP protocol stack validated"); - _output.WriteLine("✅ DI pipeline exercised (same as Program.cs)"); - _output.WriteLine("✅ Real Excel operations verified"); - _output.WriteLine("✅ Data persistence confirmed"); - _output.WriteLine("\n🚀 MCP Server E2E functionality working correctly!"); - } - - /// - /// Tests that invalid actions return helpful error messages via MCP protocol. - /// - [Fact] - public async Task InvalidSession_ReturnsHelpfulErrorMessage() - { - _output.WriteLine("Testing error handling via MCP protocol..."); - - var result = await CallToolAsync("file", new Dictionary - { - ["action"] = "close", - ["session_id"] = "nonexistent-session-id" - }); - - _output.WriteLine($"Result: {result[..Math.Min(300, result.Length)]}..."); - - // Should have success=false - var json = JsonDocument.Parse(result); - Assert.True(json.RootElement.TryGetProperty("success", out var success)); - Assert.False(success.GetBoolean()); - - // Should have helpful error message - Assert.True(json.RootElement.TryGetProperty("errorMessage", out var errorMessage)); - var errorText = errorMessage.GetString(); - Assert.NotNull(errorText); - Assert.Contains("not found", errorText, StringComparison.OrdinalIgnoreCase); - - _output.WriteLine("✓ Error message is clear and helpful via MCP protocol"); - } - - /// - /// Tests that worksheet copy-to-file (atomic operation) works WITHOUT session_id. - /// This verifies the fix for the issue where copy-to-file incorrectly required session_id. - /// - /// Atomic operations like copy-to-file and move-to-file should NOT require a session_id - /// because they manage their own Excel instances internally. - /// - [Fact] - public async Task WorksheetCopyToFile_WithoutSessionId_Works() - { - _output.WriteLine("\n✓ Testing atomic worksheet.copy-to-file (no session required)..."); - - // Create source and target Excel files for copying - var sourceFile = Path.Join(_tempDir, "CopySource.xlsx"); - var targetFile = Path.Join(_tempDir, "CopyTarget.xlsx"); - - // Step 1: Create source file with a sheet (use CreateNew for new files) - _output.WriteLine(" 1. Creating source file..."); - ExcelSession.CreateNew(sourceFile, false, (ctx, ct) => true); - - // Step 2: Create target file (empty, will receive the copied sheet) - _output.WriteLine(" 2. Creating target file..."); - ExcelSession.CreateNew(targetFile, false, (ctx, ct) => true); - - // Step 3: Call worksheet copy-to-file WITHOUT session_id (ATOMIC OPERATION) - // This is the CRITICAL TEST: the tool should accept this call without a session_id parameter - _output.WriteLine(" 3. Calling worksheet.copy-to-file without session_id..."); - var copyResult = await CallToolAsync("worksheet", new Dictionary - { - ["action"] = "copy-to-file", - ["source_file"] = sourceFile, - ["source_sheet"] = "Sheet1", - ["target_file"] = targetFile, - ["target_sheet_name"] = "CopiedSheet" - // NOTE: session_id is NOT provided - this is the test point! - // Before the fix, this would fail with "sessionId is required" - }); - - AssertSuccess(copyResult, "worksheet.copy-to-file"); - _output.WriteLine(" ✓ copy-to-file succeeded WITHOUT session_id!"); - _output.WriteLine("✓ Atomic operation (copy-to-file) correctly works without session requirement!"); - } - - /// - /// Calls a tool via the MCP protocol and returns the text response. - /// - private async Task CallToolAsync(string toolName, Dictionary arguments) - { - var result = await _client!.CallToolAsync(toolName, arguments, cancellationToken: _cts.Token); - - Assert.NotNull(result); - Assert.NotNull(result.Content); - Assert.NotEmpty(result.Content); - - var textBlock = result.Content.OfType().FirstOrDefault(); - Assert.NotNull(textBlock); - - return textBlock.Text; - } - - /// - /// Asserts the JSON response indicates success. - /// - private static void AssertSuccess(string jsonResult, string operationName) - { - Assert.NotNull(jsonResult); - - try - { - var json = JsonDocument.Parse(jsonResult); - - // Check for error property - if (json.RootElement.TryGetProperty("error", out var error)) - { - var errorMsg = error.GetString(); - Assert.Fail($"{operationName} failed with error: {errorMsg}"); - } - - // Check for Success property (PascalCase) - if (json.RootElement.TryGetProperty("Success", out var successPascal)) - { - if (!successPascal.GetBoolean()) - { - var errorMsg = json.RootElement.TryGetProperty("ErrorMessage", out var errProp) - ? errProp.GetString() - : "Unknown error"; - Assert.Fail($"{operationName} returned Success=false: {errorMsg}"); - } - } - // Check for success property (camelCase) - else if (json.RootElement.TryGetProperty("success", out var successCamel)) - { - if (!successCamel.GetBoolean()) - { - var errorMsg = json.RootElement.TryGetProperty("errorMessage", out var errProp) - ? errProp.GetString() - : "Unknown error"; - Assert.Fail($"{operationName} returned success=false: {errorMsg}"); - } - } - } - catch (JsonException ex) - { - Assert.Fail($"{operationName} returned invalid JSON: {ex.Message}\nResponse: {jsonResult}"); - } - } - - /// - /// Gets a string property from a JSON response. - /// - private static string? GetJsonProperty(string jsonResult, string propertyName) - { - var json = JsonDocument.Parse(jsonResult); - return json.RootElement.TryGetProperty(propertyName, out var prop) ? prop.GetString() : null; - } -} - - - - - diff --git a/tests/ExcelMcp.McpServer.Tests/Integration/Tools/RenameOperationsToolContractTests.cs b/tests/ExcelMcp.McpServer.Tests/Integration/Tools/RenameOperationsToolContractTests.cs deleted file mode 100644 index 8cf3bf4e..00000000 --- a/tests/ExcelMcp.McpServer.Tests/Integration/Tools/RenameOperationsToolContractTests.cs +++ /dev/null @@ -1,504 +0,0 @@ -// Copyright (c) Sbroenne. All rights reserved. -// Licensed under the MIT License. - -using System.IO.Pipelines; -using System.Text.Json; -using ModelContextProtocol.Client; -using ModelContextProtocol.Protocol; -using Xunit; -using Xunit.Abstractions; - -namespace Sbroenne.ExcelMcp.McpServer.Tests.Integration.Tools; - -/// -/// Contract tests for rename operations verifying deterministic MCP behavior. -/// These tests ensure rename operations return proper JSON responses (not exceptions) -/// for business logic errors, enabling LLM agents to handle outcomes predictably. -/// -/// Key contracts verified: -/// - Missing object → JSON with success=false, "not found" in errorMessage -/// - Name conflict → JSON with success=false, "exists/conflict" in errorMessage -/// - Invalid name → JSON with success=false, validation error -/// - No-op (same name) → JSON with success=true -/// - Success → JSON with objectType, oldName, newName -/// - Excel limitation → JSON with success=false, clear explanation -/// -[Collection("ProgramTransport")] -[Trait("Category", "Integration")] -[Trait("Speed", "Medium")] -[Trait("Layer", "McpServer")] -[Trait("Feature", "RenameContract")] -[Trait("RequiresExcel", "true")] -public class RenameOperationsToolContractTests : IAsyncLifetime, IAsyncDisposable -{ - private readonly ITestOutputHelper _output; - private readonly string _tempDir; - private readonly string _testExcelFile; - - private readonly Pipe _clientToServerPipe = new(); - private readonly Pipe _serverToClientPipe = new(); - private readonly CancellationTokenSource _cts = new(); - private McpClient? _client; - private Task? _serverTask; - private string? _sessionId; - - public RenameOperationsToolContractTests(ITestOutputHelper output) - { - _output = output; - _tempDir = Path.Join(Path.GetTempPath(), $"RenameContract_{Guid.NewGuid():N}"); - Directory.CreateDirectory(_tempDir); - _testExcelFile = Path.Join(_tempDir, "RenameContractTest.xlsx"); - - _output.WriteLine($"Test directory: {_tempDir}"); - } - - public async Task InitializeAsync() - { - Program.ConfigureTestTransport(_clientToServerPipe, _serverToClientPipe); - _serverTask = Program.Main([]); - await Task.Delay(100); - - _client = await McpClient.CreateAsync( - new StreamClientTransport( - serverInput: _clientToServerPipe.Writer.AsStream(), - serverOutput: _serverToClientPipe.Reader.AsStream()), - clientOptions: new McpClientOptions - { - ClientInfo = new() { Name = "RenameContractTestClient", Version = "1.0.0" }, - InitializationTimeout = TimeSpan.FromSeconds(30) - }, - cancellationToken: _cts.Token); - - _output.WriteLine($"✓ Connected to server: {_client.ServerInfo?.Name} v{_client.ServerInfo?.Version}"); - - // Create a fresh workbook and open session in one call (Create) - var createJson = await CallToolAsync("file", new Dictionary - { - ["action"] = "create", - ["path"] = _testExcelFile - }); - - var createDoc = JsonDocument.Parse(createJson); - Assert.True(createDoc.RootElement.GetProperty("success").GetBoolean(), - $"Failed to create test file: {createJson}"); - - _sessionId = createDoc.RootElement.GetProperty("session_id").GetString(); - Assert.NotNull(_sessionId); - - _output.WriteLine($"✓ Created test file and opened session: {_sessionId}"); - } - - #region Power Query Rename Contract Tests - - /// - /// Verifies that renaming a non-existent query returns JSON with success=false (not exception). - /// Contract: Missing object → success=false with "not found" message. - /// - [Fact] - public async Task PowerQueryRename_MissingQuery_ReturnsJsonWithSuccessFalse() - { - // Arrange - no query exists - - // Act - attempt to rename non-existent query - var json = await CallToolAsync("powerquery", new Dictionary - { - ["action"] = "rename", - ["session_id"] = _sessionId, - ["old_name"] = "NonExistentQuery", - ["new_name"] = "NewName" - }); - _output.WriteLine($"Response: {json}"); - - // Assert - should return JSON with success=false, NOT throw exception - var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - - Assert.False(root.GetProperty("success").GetBoolean(), "Expected success=false for missing query"); - Assert.True(root.TryGetProperty("errorMessage", out var errorMsg), "Expected errorMessage property"); - Assert.Contains("not found", errorMsg.GetString()!, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Verifies that renaming to a conflicting name returns JSON with success=false (not exception). - /// Contract: Name conflict → success=false with "exists" or "conflict" message. - /// - [Fact] - public async Task PowerQueryRename_NameConflict_ReturnsJsonWithSuccessFalse() - { - // Arrange - create two queries - await CreatePowerQuery("QueryA", "let x = 1 in x"); - await CreatePowerQuery("QueryB", "let y = 2 in y"); - - // Act - try to rename QueryA to QueryB (conflict) - var json = await CallToolAsync("powerquery", new Dictionary - { - ["action"] = "rename", - ["session_id"] = _sessionId, - ["old_name"] = "QueryA", - ["new_name"] = "QueryB" // Already exists! - }); - _output.WriteLine($"Response: {json}"); - - // Assert - should return JSON with success=false, NOT throw exception - var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - - Assert.False(root.GetProperty("success").GetBoolean(), "Expected success=false for name conflict"); - Assert.True(root.TryGetProperty("errorMessage", out var errorMsg), "Expected errorMessage property"); - var errorText = errorMsg.GetString()!; - Assert.True( - errorText.Contains("exists", StringComparison.OrdinalIgnoreCase) || - errorText.Contains("conflict", StringComparison.OrdinalIgnoreCase) || - errorText.Contains("already", StringComparison.OrdinalIgnoreCase), - $"Expected conflict-related error message, got: {errorText}"); - } - - /// - /// Verifies that renaming with empty new name returns JSON with success=false (not exception). - /// Contract: Invalid name → success=false with validation message. - /// - [Fact] - public async Task PowerQueryRename_EmptyNewName_ReturnsJsonWithSuccessFalse() - { - // Arrange - create a query - await CreatePowerQuery("ValidQuery", "let x = 1 in x"); - - // Act - try to rename with empty new name - var json = await CallToolAsync("powerquery", new Dictionary - { - ["action"] = "rename", - ["session_id"] = _sessionId, - ["old_name"] = "ValidQuery", - ["new_name"] = " " // Empty after trim - }); - _output.WriteLine($"Response: {json}"); - - // Assert - should return JSON with success=false, NOT throw exception - var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - - Assert.False(root.GetProperty("success").GetBoolean(), "Expected success=false for empty name"); - Assert.True(root.TryGetProperty("errorMessage", out var errorMsg), "Expected errorMessage property"); - var errorText = errorMsg.GetString()!; - Assert.True( - errorText.Contains("empty", StringComparison.OrdinalIgnoreCase) || - errorText.Contains("blank", StringComparison.OrdinalIgnoreCase) || - errorText.Contains("invalid", StringComparison.OrdinalIgnoreCase), - $"Expected empty/invalid name error message, got: {errorText}"); - } - - /// - /// Verifies that no-op rename (same name after trim) returns success=true. - /// Contract: No-op → success=true (no error, no change needed). - /// - [Fact] - public async Task PowerQueryRename_SameNameAfterTrim_ReturnsSuccessTrue() - { - // Arrange - create a query - await CreatePowerQuery("TestQuery", "let x = 1 in x"); - - // Act - rename to same name with extra whitespace - var json = await CallToolAsync("powerquery", new Dictionary - { - ["action"] = "rename", - ["session_id"] = _sessionId, - ["old_name"] = "TestQuery", - ["new_name"] = " TestQuery " // Same after trim = no-op - }); - _output.WriteLine($"Response: {json}"); - - // Assert - should return success=true (no-op is valid) - var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - - Assert.True(root.GetProperty("success").GetBoolean(), $"Expected success=true for no-op rename. Response: {json}"); - } - - /// - /// Verifies successful rename returns proper RenameResult structure. - /// Contract: Success → success=true with objectType, oldName, newName populated. - /// - [Fact] - public async Task PowerQueryRename_Success_ReturnsCompleteRenameResult() - { - // Arrange - create a query - await CreatePowerQuery("OriginalName", "let x = 1 in x"); - - // Act - perform valid rename - var json = await CallToolAsync("powerquery", new Dictionary - { - ["action"] = "rename", - ["session_id"] = _sessionId, - ["old_name"] = "OriginalName", - ["new_name"] = "NewName" - }); - _output.WriteLine($"Response: {json}"); - - // Assert - verify complete RenameResult structure - var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - - Assert.True(root.GetProperty("success").GetBoolean(), $"Expected success=true. Response: {json}"); - Assert.Equal("power-query", root.GetProperty("objectType").GetString()); - Assert.Equal("OriginalName", root.GetProperty("oldName").GetString()); - Assert.Equal("NewName", root.GetProperty("newName").GetString()); - } - - #endregion - - #region Data Model Rename Contract Tests - - /// - /// Verifies that renaming a non-existent table returns JSON with success=false (not exception). - /// Contract: Missing object → success=false with "not found" message. - /// - [Fact] - public async Task DataModelRenameTable_MissingTable_ReturnsJsonWithSuccessFalse() - { - // Arrange - no data model table exists - - // Act - attempt to rename non-existent table - var json = await CallToolAsync("datamodel", new Dictionary - { - ["action"] = "rename-table", - ["session_id"] = _sessionId, - ["old_name"] = "NonExistentTable", - ["new_name"] = "NewTableName" - }); - _output.WriteLine($"Response: {json}"); - - // Assert - should return JSON with success=false, NOT throw exception - var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - - Assert.False(root.GetProperty("success").GetBoolean(), "Expected success=false for missing table"); - Assert.True(root.TryGetProperty("errorMessage", out var errorMsg), "Expected errorMessage property"); - // When Data Model is empty, error message explains there are no tables - Assert.Contains("no tables", errorMsg.GetString()!, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Verifies that attempting rename on an existing table returns JSON explaining Excel limitation. - /// Contract: Excel limitation → success=false with clear explanation about immutable names. - /// - [Fact] - public async Task DataModelRenameTable_ExcelLimitation_ReturnsJsonWithClearError() - { - // Arrange - create a Power Query and load it to the Data Model - await CreatePowerQuery("TestData", "let Source = #table({\"Col1\", \"Col2\"}, {{\"A\", 1}, {\"B\", 2}}) in Source"); - await LoadQueryToDataModel("TestData"); - - // Act - attempt to rename the table in Data Model - var json = await CallToolAsync("datamodel", new Dictionary - { - ["action"] = "rename-table", - ["session_id"] = _sessionId, - ["old_name"] = "TestData", - ["new_name"] = "NewTableName" - }); - _output.WriteLine($"Response: {json}"); - - // Assert - should return JSON with success=false and clear explanation - var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - - Assert.False(root.GetProperty("success").GetBoolean(), "Expected success=false for Excel limitation"); - Assert.True(root.TryGetProperty("errorMessage", out var errorMsg), "Expected errorMessage property"); - var errorText = errorMsg.GetString()!; - - // Error should explain why rename cannot proceed - Assert.True( - errorText.Contains("immutable", StringComparison.OrdinalIgnoreCase) || - errorText.Contains("cannot rename", StringComparison.OrdinalIgnoreCase) || - errorText.Contains("read-only", StringComparison.OrdinalIgnoreCase) || - errorText.Contains("not supported", StringComparison.OrdinalIgnoreCase) || - errorText.Contains("not found", StringComparison.OrdinalIgnoreCase), - $"Expected clear explanation of why rename cannot proceed, got: {errorText}"); - } - - /// - /// Verifies that empty new name returns JSON with success=false (not exception). - /// Contract: Invalid name → success=false with validation message. - /// - [Fact] - public async Task DataModelRenameTable_EmptyNewName_ReturnsJsonWithSuccessFalse() - { - // Arrange - create table in Data Model - await CreatePowerQuery("DataTable", "let Source = #table({\"Col1\", \"Col2\"}, {{\"A\", 1}, {\"B\", 2}}) in Source"); - await LoadQueryToDataModel("DataTable"); - - // Act - try to rename with empty new name - var json = await CallToolAsync("datamodel", new Dictionary - { - ["action"] = "rename-table", - ["session_id"] = _sessionId, - ["old_name"] = "DataTable", - ["new_name"] = " " // Empty after trim - }); - _output.WriteLine($"Response: {json}"); - - // Assert - should return JSON with success=false, NOT throw exception - var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - - // Check for "success" property - bool isFailed = root.TryGetProperty("success", out var successProp) && !successProp.GetBoolean(); - Assert.True(isFailed, "Expected success=false for empty name"); - Assert.True(root.TryGetProperty("errorMessage", out _), "Expected errorMessage property"); - } - - #endregion - - #region Helper Methods - - /// - /// Calls a tool via the MCP protocol and returns the text response. - /// - private async Task CallToolAsync(string toolName, Dictionary arguments) - { - var result = await _client!.CallToolAsync(toolName, arguments, cancellationToken: _cts.Token); - - Assert.NotNull(result); - Assert.NotNull(result.Content); - Assert.NotEmpty(result.Content); - - var textBlock = result.Content.OfType().FirstOrDefault(); - Assert.NotNull(textBlock); - - return textBlock.Text; - } - - private async Task CreatePowerQuery(string name, string mCode) - { - var json = await CallToolAsync("powerquery", new Dictionary - { - ["action"] = "create", - ["session_id"] = _sessionId, - ["query_name"] = name, - ["m_code"] = mCode - }); - - var doc = JsonDocument.Parse(json); - Assert.True(doc.RootElement.GetProperty("success").GetBoolean(), - $"Failed to create query {name}: {json}"); - } - - private async Task LoadQueryToDataModel(string queryName) - { - var json = await CallToolAsync("powerquery", new Dictionary - { - ["action"] = "load-to", - ["session_id"] = _sessionId, - ["query_name"] = queryName, - ["load_destination"] = "load-to-data-model" - }); - - var doc = JsonDocument.Parse(json); - Assert.True(doc.RootElement.GetProperty("success").GetBoolean(), - $"Failed to load query {queryName} to data model: {json}"); - } - - #endregion - - #region Cleanup - - async ValueTask IAsyncDisposable.DisposeAsync() - { - await CleanupAsync(); - GC.SuppressFinalize(this); - } - - public async Task DisposeAsync() - { - await CleanupAsync(); - } - - private async Task CleanupAsync() - { - // Close the session first to release Excel COM resources - if (!string.IsNullOrEmpty(_sessionId) && _client != null) - { - try - { - await CallToolAsync("file", new Dictionary - { - ["action"] = "close", - ["session_id"] = _sessionId, - ["save"] = false - }); - _output.WriteLine("✓ Session closed during cleanup"); - } - catch (Exception ex) - { - _output.WriteLine($"Warning: Failed to close session: {ex.Message}"); - } - } - - // Dispose client first - signals we're done sending requests - if (_client != null) - { - await _client.DisposeAsync(); - } - - // Complete BOTH pipes to signal EOF for graceful server shutdown - _clientToServerPipe.Writer.Complete(); - _serverToClientPipe.Writer.Complete(); - - // Wait for server graceful shutdown with timeout - if (_serverTask != null) - { - var shutdownTimeout = Task.Delay(TimeSpan.FromSeconds(10)); - var completed = await Task.WhenAny(_serverTask, shutdownTimeout); - - if (completed == shutdownTimeout) - { - // Server didn't shut down in time - cancel as fallback - _output.WriteLine("Warning: Server did not shut down gracefully, forcing cancellation"); - await _cts.CancelAsync(); - try - { - await _serverTask; - } - catch (OperationCanceledException) - { - // Expected when we had to force cancel - } - } - } - - // Reset test transport for next test class - Program.ResetTestTransport(); - - _cts.Dispose(); - - // Clean up temp files - try - { - if (Directory.Exists(_tempDir)) - { - for (int i = 0; i < 3; i++) - { - try - { - Directory.Delete(_tempDir, recursive: true); - break; - } - catch (IOException) when (i < 2) - { - await Task.Delay(500); - } - } - } - } - catch (Exception ex) - { - _output.WriteLine($"Warning: Failed to cleanup temp directory: {ex.Message}"); - } - } - - #endregion -} - - - - diff --git a/tests/ExcelMcp.McpServer.Tests/Integration/Tools/TelemetryIntegrationTests.cs b/tests/ExcelMcp.McpServer.Tests/Integration/Tools/TelemetryIntegrationTests.cs deleted file mode 100644 index f001d429..00000000 --- a/tests/ExcelMcp.McpServer.Tests/Integration/Tools/TelemetryIntegrationTests.cs +++ /dev/null @@ -1,127 +0,0 @@ -// Copyright (c) Sbroenne. All rights reserved. -// Licensed under the MIT License. - -using Sbroenne.ExcelMcp.Core.Models.Actions; -using Sbroenne.ExcelMcp.McpServer.Telemetry; -using Sbroenne.ExcelMcp.McpServer.Tools; -using Xunit; -using Xunit.Abstractions; - -namespace Sbroenne.ExcelMcp.McpServer.Tests.Integration.Tools; - -/// -/// Integration test that verifies telemetry configuration and sensitive data redaction. -/// -[Trait("Category", "Integration")] -[Trait("Speed", "Fast")] -[Trait("Layer", "McpServer")] -[Trait("Feature", "Telemetry")] -public class TelemetryIntegrationTests(ITestOutputHelper output) -{ - [Fact] - public void TelemetryConfiguration_HasStableUserAndSessionIds() - { - output.WriteLine("=== TELEMETRY CONFIGURATION TEST ===\n"); - - // Get user and session IDs - var userId = ExcelMcpTelemetry.UserId; - var sessionId = ExcelMcpTelemetry.SessionId; - - output.WriteLine($"User ID: {userId}"); - output.WriteLine($"Session ID: {sessionId}"); - - // Assert - user ID should be stable (16 hex chars from SHA256) - Assert.NotNull(userId); - Assert.Equal(16, userId.Length); - Assert.True(userId.All(c => char.IsAsciiHexDigitLower(c)), "User ID should be lowercase hex"); - - // Assert - session ID should be unique per process (8 hex chars from GUID) - Assert.NotNull(sessionId); - Assert.Equal(8, sessionId.Length); - Assert.True(sessionId.All(c => char.IsAsciiHexDigit(c)), "Session ID should be hex"); - - // Verify IDs are consistent within same process - Assert.Equal(userId, ExcelMcpTelemetry.UserId); - Assert.Equal(sessionId, ExcelMcpTelemetry.SessionId); - } - - [Fact] - public void SensitiveDataRedactor_RedactsFilePaths() - { - var input = "Error loading file C:\\Users\\John\\Documents\\secret.xlsx"; - var redacted = SensitiveDataRedactor.RedactSensitiveData(input); - - output.WriteLine($"Input: {input}"); - output.WriteLine($"Redacted: {redacted}"); - - Assert.DoesNotContain("C:\\", redacted); - Assert.Contains("[REDACTED_PATH]", redacted); - } - - [Fact] - public void SensitiveDataRedactor_RedactsConnectionStrings() - { - var input = "Connection: Server=myserver;Password=secret123;User=admin"; - var redacted = SensitiveDataRedactor.RedactSensitiveData(input); - - output.WriteLine($"Input: {input}"); - output.WriteLine($"Redacted: {redacted}"); - - Assert.DoesNotContain("secret123", redacted); - Assert.Contains("[REDACTED]", redacted); - } - - [Fact] - public void SensitiveDataRedactor_RedactsEmailAddresses() - { - var input = "Contact john.doe@example.com for support"; - var redacted = SensitiveDataRedactor.RedactSensitiveData(input); - - output.WriteLine($"Input: {input}"); - output.WriteLine($"Redacted: {redacted}"); - - Assert.DoesNotContain("john.doe@example.com", redacted); - Assert.Contains("[REDACTED_EMAIL]", redacted); - } - - [Fact] - public void SensitiveDataRedactor_RedactsExceptions() - { - var exception = new InvalidOperationException("Failed to read C:\\Users\\Admin\\data.xlsx"); - var (type, message, _) = SensitiveDataRedactor.RedactException(exception); - - output.WriteLine($"Exception Type: {type}"); - output.WriteLine($"Redacted Message: {message}"); - - Assert.Equal("InvalidOperationException", type); - Assert.DoesNotContain("C:\\", message); - Assert.Contains("[REDACTED_PATH]", message); - } - - [Fact] - public void ToolInvocation_ExecutesWithTelemetry() - { - output.WriteLine("=== TOOL INVOCATION TEST ===\n"); - - // Act - call a tool method that uses ExecuteToolAction - // Using Test action since it doesn't require an actual file - var result = ExcelFileTool.ExcelFile( - FileAction.Test, - path: "C:\\fake\\test.xlsx", - session_id: null, - save: false, - show: false, - timeout_seconds: 300); - - output.WriteLine($"Tool result: {result[..Math.Min(200, result.Length)]}...\n"); - - // Assert - tool executed (telemetry is tracked internally) - Assert.NotNull(result); - Assert.Contains("success", result.ToLowerInvariant()); - } -} - - - - - diff --git a/tests/ExcelMcp.McpServer.Tests/Integration/Tools/WorksheetRenameParameterTests.cs b/tests/ExcelMcp.McpServer.Tests/Integration/Tools/WorksheetRenameParameterTests.cs deleted file mode 100644 index 0cdfa524..00000000 --- a/tests/ExcelMcp.McpServer.Tests/Integration/Tools/WorksheetRenameParameterTests.cs +++ /dev/null @@ -1,370 +0,0 @@ -using System.IO.Pipelines; -using System.Text.Json; -using ModelContextProtocol.Client; -using ModelContextProtocol.Protocol; -using Xunit; -using Xunit.Abstractions; - -namespace Sbroenne.ExcelMcp.McpServer.Tests.Integration.Tools; - -/// -/// Regression tests for Bug Report 2026-02-23. -/// Bug 2: worksheet(action: 'rename') parameters are not discoverable — callers -/// cannot determine the correct parameter names (sheet_name + target_name) because -/// target_name is documented as "Name for the target/copied worksheet" with no -/// mention of rename. -/// -/// These tests verify: -/// 1. The correct parameter combination (sheet_name + target_name) works -/// 2. Incorrect combinations fail with clear error messages -/// 3. Missing parameters produce actionable errors, not cryptic COM exceptions -/// -[Collection("ProgramTransport")] -[Trait("Category", "Integration")] -[Trait("Speed", "Medium")] -[Trait("Layer", "McpServer")] -[Trait("Feature", "Worksheets")] -[Trait("RequiresExcel", "true")] -public class WorksheetRenameParameterTests : IAsyncLifetime, IAsyncDisposable -{ - private readonly ITestOutputHelper _output; - private readonly string _tempDir; - private readonly string _testExcelFile; - - private readonly Pipe _clientToServerPipe = new(); - private readonly Pipe _serverToClientPipe = new(); - private readonly CancellationTokenSource _cts = new(); - private McpClient? _client; - private Task? _serverTask; - private string? _sessionId; - - public WorksheetRenameParameterTests(ITestOutputHelper output) - { - _output = output; - _tempDir = Path.Join(Path.GetTempPath(), $"WsRenameRegression_{Guid.NewGuid():N}"); - Directory.CreateDirectory(_tempDir); - _testExcelFile = Path.Join(_tempDir, "WorksheetRenameTest.xlsx"); - } - - public async Task InitializeAsync() - { - Program.ConfigureTestTransport(_clientToServerPipe, _serverToClientPipe); - _serverTask = Program.Main([]); - await Task.Delay(100); - - _client = await McpClient.CreateAsync( - new StreamClientTransport( - serverInput: _clientToServerPipe.Writer.AsStream(), - serverOutput: _serverToClientPipe.Reader.AsStream()), - clientOptions: new McpClientOptions - { - ClientInfo = new() { Name = "WsRenameRegressionClient", Version = "1.0.0" }, - InitializationTimeout = TimeSpan.FromSeconds(30) - }, - cancellationToken: _cts.Token); - - // Create a fresh workbook and open session - var createJson = await CallToolAsync("file", new Dictionary - { - ["action"] = "create", - ["path"] = _testExcelFile - }); - - var createDoc = JsonDocument.Parse(createJson); - Assert.True(createDoc.RootElement.GetProperty("success").GetBoolean(), - $"Failed to create test file: {createJson}"); - - _sessionId = createDoc.RootElement.GetProperty("session_id").GetString(); - Assert.NotNull(_sessionId); - } - - #region Bug 2: Correct parameter combination - - /// - /// Verifies that the correct parameter combination (sheet_name + target_name) works. - /// This is the only working combination, but it is not obvious from the parameter descriptions. - /// - [Fact] - public async Task Rename_WithSheetNameAndTargetName_Succeeds() - { - // Arrange — create a sheet to rename - await CallToolAsync("worksheet", new Dictionary - { - ["action"] = "create", - ["session_id"] = _sessionId, - ["sheet_name"] = "OriginalSheet" - }); - - // Act — rename using the correct (but non-obvious) parameter combination - var json = await CallToolAsync("worksheet", new Dictionary - { - ["action"] = "rename", - ["session_id"] = _sessionId, - ["sheet_name"] = "OriginalSheet", // Maps to oldName - ["target_name"] = "RenamedSheet" // Maps to newName - }); - _output.WriteLine($"Response: {json}"); - - // Assert - var doc = JsonDocument.Parse(json); - Assert.True(doc.RootElement.GetProperty("success").GetBoolean(), - $"Rename with sheet_name + target_name should succeed. Response: {json}"); - - // Verify the sheet was actually renamed - var listJson = await CallToolAsync("worksheet", new Dictionary - { - ["action"] = "list", - ["session_id"] = _sessionId - }); - Assert.Contains("RenamedSheet", listJson); - Assert.DoesNotContain("OriginalSheet", listJson); - } - - #endregion - - #region Bug 2: Parameter combinations that fail (as reported) - - /// - /// Bug report attempt 1: sheet_name + target_sheet_name fails. - /// User guessed target_sheet_name (which is a separate parameter for cross-file ops). - /// Expected error: "newName is required" because target_name was not provided. - /// - [Fact] - public async Task Rename_WithSheetNameAndTargetSheetName_FailsWithNewNameRequired() - { - // Arrange - await CallToolAsync("worksheet", new Dictionary - { - ["action"] = "create", - ["session_id"] = _sessionId, - ["sheet_name"] = "TestSheet1" - }); - - // Act — user's attempt 1 from bug report - var json = await CallToolAsync("worksheet", new Dictionary - { - ["action"] = "rename", - ["session_id"] = _sessionId, - ["sheet_name"] = "TestSheet1", - ["target_sheet_name"] = "NewName" // Wrong param! target_sheet_name is for cross-file ops - }); - _output.WriteLine($"Response: {json}"); - - // Assert — should fail because target_name (newName) is null - var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - - // The response should indicate failure with a meaningful error about the missing parameter - Assert.False(root.GetProperty("success").GetBoolean(), - "Should fail when target_sheet_name is used instead of target_name"); - Assert.True(root.TryGetProperty("errorMessage", out var errorMsg), - "Expected errorMessage in response"); - Assert.Contains("newName", errorMsg.GetString()!, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Bug report attempt 2: source_name + target_name fails. - /// User guessed source_name (which is for copy operations). - /// Expected error: "oldName is required" because sheet_name was not provided. - /// - [Fact] - public async Task Rename_WithSourceNameAndTargetName_FailsWithOldNameRequired() - { - // Arrange - await CallToolAsync("worksheet", new Dictionary - { - ["action"] = "create", - ["session_id"] = _sessionId, - ["sheet_name"] = "TestSheet2" - }); - - // Act — user's attempt 2 from bug report - var json = await CallToolAsync("worksheet", new Dictionary - { - ["action"] = "rename", - ["session_id"] = _sessionId, - ["source_name"] = "TestSheet2", // Wrong param! source_name is for copy - ["target_name"] = "NewName2" // Correct param for newName - }); - _output.WriteLine($"Response: {json}"); - - // Assert — should fail because sheet_name (oldName) is null - var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - - Assert.False(root.GetProperty("success").GetBoolean(), - "Should fail when source_name is used instead of sheet_name"); - Assert.True(root.TryGetProperty("errorMessage", out var errorMsg), - "Expected errorMessage in response"); - Assert.Contains("oldName", errorMsg.GetString()!, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Bug report attempt 3: sheet_name + source_sheet fails. - /// User guessed source_sheet (which is for cross-file move/copy). - /// Expected error: "newName is required" because target_name was not provided. - /// - [Fact] - public async Task Rename_WithSheetNameAndSourceSheet_FailsWithNewNameRequired() - { - // Arrange - await CallToolAsync("worksheet", new Dictionary - { - ["action"] = "create", - ["session_id"] = _sessionId, - ["sheet_name"] = "TestSheet3" - }); - - // Act — user's attempt 3 from bug report - var json = await CallToolAsync("worksheet", new Dictionary - { - ["action"] = "rename", - ["session_id"] = _sessionId, - ["sheet_name"] = "TestSheet3", - ["source_sheet"] = "NewName3" // Wrong param! source_sheet is for cross-file ops - }); - _output.WriteLine($"Response: {json}"); - - // Assert — should fail because target_name (newName) is null - var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - - Assert.False(root.GetProperty("success").GetBoolean(), - "Should fail when source_sheet is used instead of target_name"); - Assert.True(root.TryGetProperty("errorMessage", out var errorMsg), - "Expected errorMessage in response"); - Assert.Contains("newName", errorMsg.GetString()!, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Verify that both old and new name missing produces a clear error. - /// Tests the case where a caller provides only the action and session_id. - /// - [Fact] - public async Task Rename_WithNoNameParameters_FailsWithOldNameRequired() - { - // Act — call rename with no name parameters at all - var json = await CallToolAsync("worksheet", new Dictionary - { - ["action"] = "rename", - ["session_id"] = _sessionId - }); - _output.WriteLine($"Response: {json}"); - - // Assert — should fail with old name being the first validation error - var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - - Assert.False(root.GetProperty("success").GetBoolean(), - "Should fail when no name parameters are provided"); - } - - #endregion - - #region Helper Methods - - private async Task CallToolAsync(string toolName, Dictionary arguments) - { - var result = await _client!.CallToolAsync(toolName, arguments, cancellationToken: _cts.Token); - - Assert.NotNull(result); - Assert.NotNull(result.Content); - Assert.NotEmpty(result.Content); - - var textBlock = result.Content.OfType().FirstOrDefault(); - Assert.NotNull(textBlock); - - return textBlock.Text; - } - - #endregion - - #region Cleanup - - async ValueTask IAsyncDisposable.DisposeAsync() - { - await CleanupAsync(); - GC.SuppressFinalize(this); - } - - public async Task DisposeAsync() - { - await CleanupAsync(); - } - - private async Task CleanupAsync() - { - if (!string.IsNullOrEmpty(_sessionId) && _client != null) - { - try - { - await CallToolAsync("file", new Dictionary - { - ["action"] = "close", - ["session_id"] = _sessionId, - ["save"] = false - }); - } - catch (Exception ex) - { - _output.WriteLine($"Warning: Failed to close session: {ex.Message}"); - } - } - - if (_client != null) - { - await _client.DisposeAsync(); - } - - _clientToServerPipe.Writer.Complete(); - _serverToClientPipe.Writer.Complete(); - - if (_serverTask != null) - { - var shutdownTimeout = Task.Delay(TimeSpan.FromSeconds(10)); - var completed = await Task.WhenAny(_serverTask, shutdownTimeout); - - if (completed == shutdownTimeout) - { - _output.WriteLine("Warning: Server did not shut down gracefully, forcing cancellation"); - await _cts.CancelAsync(); - try - { - await _serverTask; - } - catch (OperationCanceledException) - { - // Expected - } - } - } - - Program.ResetTestTransport(); - _cts.Dispose(); - - try - { - if (Directory.Exists(_tempDir)) - { - for (int i = 0; i < 3; i++) - { - try - { - Directory.Delete(_tempDir, recursive: true); - break; - } - catch (IOException) when (i < 2) - { - await Task.Delay(500); - } - } - } - } - catch - { - // Cleanup is best-effort - } - } - - #endregion -} diff --git a/tests/ExcelMcp.McpServer.Tests/Unit/TelemetryTests.cs b/tests/ExcelMcp.McpServer.Tests/Unit/TelemetryTests.cs deleted file mode 100644 index 254ee153..00000000 --- a/tests/ExcelMcp.McpServer.Tests/Unit/TelemetryTests.cs +++ /dev/null @@ -1,207 +0,0 @@ -// Copyright (c) Sbroenne. All rights reserved. -// Licensed under the MIT License. - -using Sbroenne.ExcelMcp.McpServer.Telemetry; -using Xunit; - -namespace Sbroenne.ExcelMcp.McpServer.Tests.Unit; - -/// -/// Tests for telemetry configuration and sensitive data redaction. -/// -[Trait("Category", "Unit")] -[Trait("Speed", "Fast")] -[Trait("Layer", "McpServer")] -[Trait("Feature", "Telemetry")] -public class TelemetryTests -{ - #region ExcelMcpTelemetry Tests - - [Fact] - public void SessionId_IsNotEmpty() - { - // Session ID should be generated on startup - Assert.False(string.IsNullOrEmpty(ExcelMcpTelemetry.SessionId)); - } - - [Fact] - public void SessionId_IsEightCharacters() - { - // Session ID should be first 8 chars of GUID - Assert.Equal(8, ExcelMcpTelemetry.SessionId.Length); - } - - [Fact] - public void UserId_IsNotEmpty() - { - // User ID should be generated from machine identity - Assert.False(string.IsNullOrEmpty(ExcelMcpTelemetry.UserId)); - } - - [Fact] - public void UserId_IsSixteenCharacters() - { - // User ID should be first 16 chars of SHA256 hash - Assert.Equal(16, ExcelMcpTelemetry.UserId.Length); - } - - [Fact] - public void UserId_IsLowercaseHex() - { - // User ID should be lowercase hex characters only - Assert.True(ExcelMcpTelemetry.UserId.All(c => char.IsAsciiHexDigitLower(c))); - } - - [Fact] - public void GetConnectionString_ReturnsNullForPlaceholder() - { - // The placeholder should not be treated as a valid connection string - // (In dev builds, it's "__APPINSIGHTS_CONNECTION_STRING__") - var connectionString = ExcelMcpTelemetry.GetConnectionString(); - - // Either null (placeholder) or a real connection string (CI build) - // We can't assert null directly because CI might inject a real one - if (connectionString != null) - { - Assert.DoesNotContain("__", connectionString); - } - } - - #endregion - - #region SensitiveDataRedactor Tests - - [Theory] - [InlineData(@"C:\Users\john\Documents\file.xlsx", "[REDACTED_PATH]")] - [InlineData(@"D:\source\project\data.csv", "[REDACTED_PATH]")] - [InlineData(@"E:\folder\subfolder\test.txt", "[REDACTED_PATH]")] - public void RedactSensitiveData_RedactsWindowsPaths(string input, string expected) - { - var result = SensitiveDataRedactor.RedactSensitiveData(input); - Assert.Equal(expected, result); - } - - [Theory] - [InlineData(@"\\server\share\file.xlsx", "[REDACTED_PATH]")] - [InlineData(@"\\192.168.1.1\data\report.csv", "[REDACTED_PATH]")] - [InlineData(@"\\company.local\shared\docs\file.txt", "[REDACTED_PATH]")] - public void RedactSensitiveData_RedactsUncPaths(string input, string expected) - { - var result = SensitiveDataRedactor.RedactSensitiveData(input); - Assert.Equal(expected, result); - } - - [Theory] - [InlineData("Password=secret123", "Password=[REDACTED]")] - [InlineData("pwd=mypassword", "pwd=[REDACTED]")] - [InlineData("User Id=admin;Password=secret", "User Id=admin;Password=[REDACTED]")] - public void RedactSensitiveData_RedactsPasswords(string input, string expected) - { - var result = SensitiveDataRedactor.RedactSensitiveData(input); - Assert.Equal(expected, result); - } - - [Theory] - [InlineData("user@example.com", "[REDACTED_EMAIL]")] - [InlineData("john.doe@company.org", "[REDACTED_EMAIL]")] - [InlineData("Contact: admin@test.co.uk for help", "Contact: [REDACTED_EMAIL] for help")] - public void RedactSensitiveData_RedactsEmails(string input, string expected) - { - var result = SensitiveDataRedactor.RedactSensitiveData(input); - Assert.Equal(expected, result); - } - - [Theory] - [InlineData("https://user:pass@server.com/api", "https://[REDACTED]@server.com/api")] - [InlineData("http://admin:secret123@localhost:8080", "http://[REDACTED]@localhost:8080")] - public void RedactSensitiveData_RedactsUrlCredentials(string input, string expected) - { - var result = SensitiveDataRedactor.RedactSensitiveData(input); - Assert.Equal(expected, result); - } - - [Theory] - [InlineData("Operation completed successfully", "Operation completed successfully")] - [InlineData("Error code 500", "Error code 500")] - [InlineData("Range A1:B10", "Range A1:B10")] - public void RedactSensitiveData_PreservesNonSensitiveData(string input, string expected) - { - var result = SensitiveDataRedactor.RedactSensitiveData(input); - Assert.Equal(expected, result); - } - - [Fact] - public void RedactSensitiveData_HandlesEmptyInput() - { - var result = SensitiveDataRedactor.RedactSensitiveData(string.Empty); - Assert.Equal(string.Empty, result); - } - - [Fact] - public void RedactSensitiveData_RedactsMultipleSensitiveItems() - { - var input = @"Error accessing C:\Users\john\file.xlsx: user@example.com failed with Password=secret"; - var result = SensitiveDataRedactor.RedactSensitiveData(input); - - Assert.DoesNotContain(@"C:\Users", result); - Assert.DoesNotContain("user@example.com", result); - Assert.DoesNotContain("Password=secret", result); - Assert.Contains("[REDACTED_PATH]", result); - Assert.Contains("[REDACTED_EMAIL]", result); - Assert.Contains("[REDACTED]", result); - } - - [Fact] - public void RedactException_RedactsExceptionMessage() - { - var exception = new InvalidOperationException(@"Failed to open C:\Users\admin\secret.xlsx"); - - var (type, message, _) = SensitiveDataRedactor.RedactException(exception); - - Assert.Equal("InvalidOperationException", type); - Assert.Contains("[REDACTED_PATH]", message); - Assert.DoesNotContain(@"C:\Users", message); - } - - [Fact] - public void RedactException_PreservesExceptionType() - { - var exception = new ArgumentException("Test error"); - - var (type, message, _) = SensitiveDataRedactor.RedactException(exception); - - Assert.Equal("ArgumentException", type); - Assert.Equal("Test error", message); - } - - [Fact] - public void RedactException_RedactsStackTrace() - { - InvalidOperationException caughtException; - try - { - // Create exception with stack trace containing path - throw new InvalidOperationException(@"Error at C:\Users\test\file.cs line 42"); - } - catch (InvalidOperationException ex) - { - caughtException = ex; - } - - var (_, message, stackTrace) = SensitiveDataRedactor.RedactException(caughtException); - - Assert.Contains("[REDACTED_PATH]", message); - // Stack trace will contain the actual test file path which should be redacted - if (stackTrace != null) - { - // The stack trace contains this test file's path - Assert.DoesNotContain(@"C:\Users", stackTrace); - } - } - - #endregion -} - - - - diff --git a/tests/ExcelMcp.CLI.Tests/Helpers/CliProcessHelper.cs b/tests/PptMcp.CLI.Tests/Helpers/CliProcessHelper.cs similarity index 82% rename from tests/ExcelMcp.CLI.Tests/Helpers/CliProcessHelper.cs rename to tests/PptMcp.CLI.Tests/Helpers/CliProcessHelper.cs index c59f5460..9a78b5ab 100644 --- a/tests/ExcelMcp.CLI.Tests/Helpers/CliProcessHelper.cs +++ b/tests/PptMcp.CLI.Tests/Helpers/CliProcessHelper.cs @@ -1,38 +1,38 @@ using System.Diagnostics; using System.Text.Json; -namespace Sbroenne.ExcelMcp.CLI.Tests.Helpers; +namespace PptMcp.CLI.Tests.Helpers; /// -/// Helper for running excelcli as a subprocess and capturing output. +/// Helper for running pptcli as a subprocess and capturing output. /// Used by integration tests that verify CLI behavior end-to-end. /// internal static class CliProcessHelper { /// - /// Gets the path to the excelcli executable. + /// Gets the path to the pptcli executable. /// Finds it relative to the test assembly location. /// public static string GetExePath() { // The CLI project is a project reference, so the exe is in the same output directory var testDir = AppContext.BaseDirectory; - var exePath = Path.Combine(testDir, "excelcli.exe"); + var exePath = Path.Combine(testDir, "pptcli.exe"); if (!File.Exists(exePath)) { throw new FileNotFoundException( - $"excelcli.exe not found at {exePath}. Ensure ExcelMcp.CLI is a project reference."); + $"pptcli.exe not found at {exePath}. Ensure PptMcp.CLI is a project reference."); } return exePath; } /// - /// Runs an excelcli command and captures the result. + /// Runs an pptcli command and captures the result. /// Always uses -q (quiet) mode for clean JSON output. /// - /// Arguments to pass to excelcli (e.g., "diag ping") + /// Arguments to pass to pptcli (e.g., "diag ping") /// Timeout in milliseconds (default: 30000) /// Optional environment variables to set on the process /// Process result with stdout, stderr, and exit code @@ -75,7 +75,7 @@ public static async Task RunAsync(string args, int timeoutMs = 30000, if (!completed) { process.Kill(entireProcessTree: true); - throw new TimeoutException($"excelcli timed out after {timeoutMs}ms. Args: {args}"); + throw new TimeoutException($"pptcli timed out after {timeoutMs}ms. Args: {args}"); } return new CliResult @@ -87,7 +87,7 @@ public static async Task RunAsync(string args, int timeoutMs = 30000, } /// - /// Runs an excelcli command and parses the JSON output. + /// Runs an pptcli command and parses the JSON output. /// public static async Task<(CliResult Result, JsonDocument Json)> RunJsonAsync( string args, int timeoutMs = 30000, Dictionary? environmentVariables = null) @@ -99,7 +99,7 @@ public static async Task RunAsync(string args, int timeoutMs = 30000, } /// -/// Result of running excelcli as a subprocess. +/// Result of running pptcli as a subprocess. /// internal sealed class CliResult { diff --git a/tests/ExcelMcp.CLI.Tests/Integration/BatchCommandTests.cs b/tests/PptMcp.CLI.Tests/Integration/BatchCommandTests.cs similarity index 98% rename from tests/ExcelMcp.CLI.Tests/Integration/BatchCommandTests.cs rename to tests/PptMcp.CLI.Tests/Integration/BatchCommandTests.cs index 26e83f0d..cc43fa9a 100644 --- a/tests/ExcelMcp.CLI.Tests/Integration/BatchCommandTests.cs +++ b/tests/PptMcp.CLI.Tests/Integration/BatchCommandTests.cs @@ -1,20 +1,20 @@ using System.Text.Json; -using Sbroenne.ExcelMcp.CLI.Tests.Helpers; +using PptMcp.CLI.Tests.Helpers; using Xunit; using Xunit.Abstractions; -namespace Sbroenne.ExcelMcp.CLI.Tests.Integration; +namespace PptMcp.CLI.Tests.Integration; /// /// Integration tests for the batch CLI command. /// Tests the full CLI pipeline: process launch → batch parsing → daemon dispatch → NDJSON output. -/// Uses diag commands (ping, echo) to validate batch infrastructure without requiring Excel. +/// Uses diag commands (ping, echo) to validate batch infrastructure without requiring PowerPoint. /// [Collection("Service")] [Trait("Layer", "CLI")] [Trait("Category", "Integration")] [Trait("Feature", "Batch")] -[Trait("RequiresExcel", "false")] +[Trait("RequiresPowerPoint", "false")] [Trait("Speed", "Fast")] public sealed class BatchCommandTests : IDisposable { diff --git a/tests/ExcelMcp.CLI.Tests/Integration/CliDaemonTests.cs b/tests/PptMcp.CLI.Tests/Integration/CliDaemonTests.cs similarity index 94% rename from tests/ExcelMcp.CLI.Tests/Integration/CliDaemonTests.cs rename to tests/PptMcp.CLI.Tests/Integration/CliDaemonTests.cs index fb7bd200..26567851 100644 --- a/tests/ExcelMcp.CLI.Tests/Integration/CliDaemonTests.cs +++ b/tests/PptMcp.CLI.Tests/Integration/CliDaemonTests.cs @@ -1,25 +1,25 @@ using System.Diagnostics; -using Sbroenne.ExcelMcp.CLI.Tests.Helpers; +using PptMcp.CLI.Tests.Helpers; using Xunit; using Xunit.Abstractions; -namespace Sbroenne.ExcelMcp.CLI.Tests.Integration; +namespace PptMcp.CLI.Tests.Integration; /// -/// Integration tests for the CLI daemon process (excelcli service run). +/// Integration tests for the CLI daemon process (pptcli service run). /// Verifies the daemon starts, accepts pipe connections, and shuts down cleanly. -/// These tests do NOT require Excel — they validate daemon infrastructure. +/// These tests do NOT require PowerPoint — they validate daemon infrastructure. /// Uses a test-specific pipe name to avoid conflicting with ServiceFixture. /// [Trait("Layer", "CLI")] [Trait("Category", "Integration")] [Trait("Feature", "ServiceDaemon")] -[Trait("RequiresExcel", "false")] +[Trait("RequiresPowerPoint", "false")] [Trait("Speed", "Medium")] public sealed class CliDaemonTests : IAsyncLifetime { private readonly ITestOutputHelper _output; - private readonly string _testPipeName = $"excelmcp-test-daemon-{Guid.NewGuid():N}"; + private readonly string _testPipeName = $"PptMcp-test-daemon-{Guid.NewGuid():N}"; private Process? _daemonProcess; public CliDaemonTests(ITestOutputHelper output) => _output = output; @@ -36,7 +36,7 @@ public Task DisposeAsync() return Task.CompletedTask; } - private Dictionary TestEnv => new() { ["EXCELMCP_CLI_PIPE"] = _testPipeName }; + private Dictionary TestEnv => new() { ["PptMcp_CLI_PIPE"] = _testPipeName }; [Fact] public async Task ServiceRun_StartsAndAcceptsConnections() diff --git a/tests/ExcelMcp.CLI.Tests/Integration/DiagCommandTests.cs b/tests/PptMcp.CLI.Tests/Integration/DiagCommandTests.cs similarity index 95% rename from tests/ExcelMcp.CLI.Tests/Integration/DiagCommandTests.cs rename to tests/PptMcp.CLI.Tests/Integration/DiagCommandTests.cs index f354e2cd..81c9571d 100644 --- a/tests/ExcelMcp.CLI.Tests/Integration/DiagCommandTests.cs +++ b/tests/PptMcp.CLI.Tests/Integration/DiagCommandTests.cs @@ -1,21 +1,21 @@ using System.Text.Json; -using Sbroenne.ExcelMcp.CLI.Tests.Helpers; +using PptMcp.CLI.Tests.Helpers; using Xunit; using Xunit.Abstractions; -namespace Sbroenne.ExcelMcp.CLI.Tests.Integration; +namespace PptMcp.CLI.Tests.Integration; /// /// Integration tests for the diag CLI command. /// Tests the full CLI pipeline end-to-end: process launch → service dispatch → JSON response. -/// These tests do NOT require Excel — they validate CLI infrastructure (parameter parsing, +/// These tests do NOT require PowerPoint — they validate CLI infrastructure (parameter parsing, /// validation, routing, JSON serialization, exit codes). /// [Collection("Service")] [Trait("Layer", "CLI")] [Trait("Category", "Integration")] [Trait("Feature", "Diag")] -[Trait("RequiresExcel", "false")] +[Trait("RequiresPowerPoint", "false")] [Trait("Speed", "Fast")] public sealed class DiagCommandTests { @@ -135,7 +135,8 @@ public async Task ValidateParams_RequiredOnly_DefaultsApplied() var parameters = json.RootElement.GetProperty("parameters"); Assert.Equal("test", parameters.GetProperty("name").GetString()); Assert.Equal(5, parameters.GetProperty("count").GetInt32()); - Assert.Null(parameters.GetProperty("label").GetString()); + // label is null → omitted by WhenWritingNull + Assert.False(parameters.TryGetProperty("label", out _)); Assert.False(parameters.GetProperty("verbose").GetBoolean()); } @@ -177,8 +178,8 @@ public async Task InvalidAction_ReturnsError() var result = await CliProcessHelper.RunAsync("diag nonexistent"); _output.WriteLine(result.Stdout); - Assert.Equal(1, result.ExitCode); - Assert.Contains("Invalid action", result.Stdout); + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("Unknown command", result.Stdout); } // ============================================ diff --git a/tests/ExcelMcp.CLI.Tests/Integration/ServiceFixture.cs b/tests/PptMcp.CLI.Tests/Integration/ServiceFixture.cs similarity index 67% rename from tests/ExcelMcp.CLI.Tests/Integration/ServiceFixture.cs rename to tests/PptMcp.CLI.Tests/Integration/ServiceFixture.cs index 0b60fe94..59af0899 100644 --- a/tests/ExcelMcp.CLI.Tests/Integration/ServiceFixture.cs +++ b/tests/PptMcp.CLI.Tests/Integration/ServiceFixture.cs @@ -1,20 +1,20 @@ -using Sbroenne.ExcelMcp.Service; +using PptMcp.Service; using Xunit; -namespace Sbroenne.ExcelMcp.CLI.Tests.Integration; +namespace PptMcp.CLI.Tests.Integration; /// -/// Fixture that starts an in-process ExcelMCP service for CLI integration tests. +/// Fixture that starts an in-process PptMcp service for CLI integration tests. /// Uses the CLI pipe name so CLI commands can connect to it. /// public sealed class ServiceFixture : IAsyncLifetime, IDisposable { - private ExcelMcpService? _service; + private PptMcpService? _service; public async Task InitializeAsync() { var pipeName = ServiceSecurity.GetCliPipeName(); - _service = new ExcelMcpService(); + _service = new PptMcpService(); _ = Task.Run(() => _service.RunAsync(pipeName)); // Wait for pipe server to be ready @@ -28,7 +28,7 @@ public async Task InitializeAsync() } } - throw new InvalidOperationException("ExcelMCP service did not start within timeout."); + throw new InvalidOperationException("PptMcp service did not start within timeout."); } public Task DisposeAsync() @@ -46,8 +46,8 @@ public void Dispose() } /// -/// Collection definition for tests that require the ExcelMCP service. -/// Apply [Collection("Service")] to test classes that call excelcli commands. +/// Collection definition for tests that require the PptMcp service. +/// Apply [Collection("Service")] to test classes that call pptcli commands. /// [CollectionDefinition("Service")] public sealed class ServiceTestGroup : ICollectionFixture; diff --git a/tests/ExcelMcp.CLI.Tests/ExcelMcp.CLI.Tests.csproj b/tests/PptMcp.CLI.Tests/PptMcp.CLI.Tests.csproj similarity index 61% rename from tests/ExcelMcp.CLI.Tests/ExcelMcp.CLI.Tests.csproj rename to tests/PptMcp.CLI.Tests/PptMcp.CLI.Tests.csproj index 858365dc..41abe0c5 100644 --- a/tests/ExcelMcp.CLI.Tests/ExcelMcp.CLI.Tests.csproj +++ b/tests/PptMcp.CLI.Tests/PptMcp.CLI.Tests.csproj @@ -1,7 +1,7 @@ - net10.0-windows + net9.0-windows true $(NoWarn);CS1591;CA1707 latest @@ -11,8 +11,8 @@ true - Sbroenne.ExcelMcp.CLI.Tests - Sbroenne.ExcelMcp.CLI.Tests + PptMcp.CLI.Tests + PptMcp.CLI.Tests @@ -29,14 +29,13 @@ - - - + + + - - + diff --git a/tests/ExcelMcp.CLI.Tests/Unit/ActionValidatorTests.cs b/tests/PptMcp.CLI.Tests/Unit/ActionValidatorTests.cs similarity index 80% rename from tests/ExcelMcp.CLI.Tests/Unit/ActionValidatorTests.cs rename to tests/PptMcp.CLI.Tests/Unit/ActionValidatorTests.cs index a7ccbe76..0ed53043 100644 --- a/tests/ExcelMcp.CLI.Tests/Unit/ActionValidatorTests.cs +++ b/tests/PptMcp.CLI.Tests/Unit/ActionValidatorTests.cs @@ -1,11 +1,11 @@ using System.Reflection; using System.Text.Json; -using Sbroenne.ExcelMcp.CLI.Commands; -using Sbroenne.ExcelMcp.Generated; +using PptMcp.CLI.Commands; +using PptMcp.Generated; using Spectre.Console.Cli; using Xunit; -namespace Sbroenne.ExcelMcp.CLI.Tests.Unit; +namespace PptMcp.CLI.Tests.Unit; [Trait("Layer", "CLI")] [Trait("Category", "Unit")] @@ -15,37 +15,24 @@ public sealed class ActionValidatorTests { public static IEnumerable ActionEnumTypes => [ - [typeof(RangeAction), typeof(ServiceRegistry.Range)], - [typeof(RangeEditAction), typeof(ServiceRegistry.RangeEdit)], - [typeof(RangeFormatAction), typeof(ServiceRegistry.RangeFormat)], - [typeof(RangeLinkAction), typeof(ServiceRegistry.RangeLink)] + [typeof(SlideAction), typeof(ServiceRegistry.Slide)], + [typeof(ShapeAction), typeof(ServiceRegistry.Shape)], + [typeof(TextAction), typeof(ServiceRegistry.Text)], + [typeof(NotesAction), typeof(ServiceRegistry.Notes)] ]; private static readonly string[] ExpectedCommands = [ "session", - "sheet", - "worksheetstyle", - "range", - "rangeedit", - "rangeformat", - "rangelink", - "table", - "tablecolumn", - "powerquery", - "pivottable", - "pivottablefield", - "pivottablecalc", - "chart", - "chartconfig", - "connection", - "calculationmode", - "namedrange", - "conditionalformat", - "vba", - "datamodel", - "datamodelrelationship", - "slicer" + "slide", + "shape", + "text", + "notes", + "master", + "export", + "transition", + "image", + "file" ]; [Theory] diff --git a/tests/ExcelMcp.CLI.Tests/Unit/ExcelMcpServiceErrorTests.cs b/tests/PptMcp.CLI.Tests/Unit/PptMcpServiceErrorTests.cs similarity index 90% rename from tests/ExcelMcp.CLI.Tests/Unit/ExcelMcpServiceErrorTests.cs rename to tests/PptMcp.CLI.Tests/Unit/PptMcpServiceErrorTests.cs index 22be816e..f6975c26 100644 --- a/tests/ExcelMcp.CLI.Tests/Unit/ExcelMcpServiceErrorTests.cs +++ b/tests/PptMcp.CLI.Tests/Unit/PptMcpServiceErrorTests.cs @@ -1,10 +1,10 @@ -using Sbroenne.ExcelMcp.Service; +using PptMcp.Service; using Xunit; -namespace Sbroenne.ExcelMcp.CLI.Tests.Unit; +namespace PptMcp.CLI.Tests.Unit; /// -/// Unit tests for ExcelMcpService error handling. +/// Unit tests for PptMcpService error handling. /// /// REGRESSION TESTS for Bug 5 (GitHub #482): Top-level exception catch in ProcessAsync /// only included ex.Message, losing the exception type. This makes debugging impossible @@ -12,9 +12,9 @@ namespace Sbroenne.ExcelMcp.CLI.Tests.Unit; /// [Trait("Layer", "Service")] [Trait("Category", "Unit")] -[Trait("Feature", "ExcelMcpService")] +[Trait("Feature", "PptMcpService")] [Trait("Speed", "Fast")] -public sealed class ExcelMcpServiceErrorTests +public sealed class PptMcpServiceErrorTests { /// /// REGRESSION TEST for Bug 5 (#482): When an unexpected exception escapes @@ -26,7 +26,7 @@ public sealed class ExcelMcpServiceErrorTests public async Task ProcessAsync_UnexpectedExceptionEscapesRouter_ErrorMessageIncludesTypeName() { // Arrange - using var service = new ExcelMcpService(); + using var service = new PptMcpService(); // null Command triggers NullReferenceException in parts = request.Command.Split(...) // This exercises the top-level catch (Exception ex) block in ProcessAsync @@ -56,7 +56,7 @@ public async Task ProcessAsync_UnexpectedExceptionEscapesRouter_ErrorMessageIncl public async Task ProcessAsync_UnknownCategory_ReturnsNormalErrorWithoutTypeName() { // Arrange - using var service = new ExcelMcpService(); + using var service = new PptMcpService(); var request = new ServiceRequest { Command = "unknowncategory.someaction" }; // Act @@ -80,7 +80,7 @@ public async Task ProcessAsync_UnknownCategory_ReturnsNormalErrorWithoutTypeName public async Task ProcessAsync_SessionCommandWithInvalidSessionId_ReturnsUsableError() { // Arrange - using var service = new ExcelMcpService(); + using var service = new PptMcpService(); // Send a sheet.list command with a session ID that doesn't exist var request = new ServiceRequest diff --git a/tests/ExcelMcp.CLI.Tests/Unit/StreamJsonRpcTests.cs b/tests/PptMcp.CLI.Tests/Unit/StreamJsonRpcTests.cs similarity index 88% rename from tests/ExcelMcp.CLI.Tests/Unit/StreamJsonRpcTests.cs rename to tests/PptMcp.CLI.Tests/Unit/StreamJsonRpcTests.cs index 64468a61..8aeee1aa 100644 --- a/tests/ExcelMcp.CLI.Tests/Unit/StreamJsonRpcTests.cs +++ b/tests/PptMcp.CLI.Tests/Unit/StreamJsonRpcTests.cs @@ -1,10 +1,10 @@ using Nerdbank.Streams; -using Sbroenne.ExcelMcp.Service; -using Sbroenne.ExcelMcp.Service.Rpc; +using PptMcp.Service; +using PptMcp.Service.Rpc; using StreamJsonRpc; using Xunit; -namespace Sbroenne.ExcelMcp.CLI.Tests.Unit; +namespace PptMcp.CLI.Tests.Unit; /// /// Tests for the StreamJsonRpc-based CLI↔daemon communication layer. @@ -17,11 +17,11 @@ namespace Sbroenne.ExcelMcp.CLI.Tests.Unit; [Trait("Speed", "Fast")] public sealed class StreamJsonRpcTests : IDisposable { - private readonly ExcelMcpService _service = new(); + private readonly PptMcpService _service = new(); /// /// Validates end-to-end RPC round-trip: client sends ServiceRequest through StreamJsonRpc, - /// DaemonRpcTarget delegates to ExcelMcpService.ProcessAsync, response returns correctly. + /// DaemonRpcTarget delegates to PptMcpService.ProcessAsync, response returns correctly. /// [Fact] public async Task ProcessCommandAsync_RoundTrip_ReturnsServiceResponse() @@ -30,7 +30,7 @@ public async Task ProcessCommandAsync_RoundTrip_ReturnsServiceResponse() var (serverStream, clientStream) = FullDuplexStream.CreatePair(); var rpcTarget = new DaemonRpcTarget(_service); using var serverRpc = JsonRpc.Attach(serverStream, rpcTarget); - var clientProxy = JsonRpc.Attach(clientStream); + var clientProxy = JsonRpc.Attach(clientStream); try { @@ -59,7 +59,7 @@ public async Task ProcessCommandAsync_UnknownCategory_ReturnsErrorResponse() var (serverStream, clientStream) = FullDuplexStream.CreatePair(); var rpcTarget = new DaemonRpcTarget(_service); using var serverRpc = JsonRpc.Attach(serverStream, rpcTarget); - var clientProxy = JsonRpc.Attach(clientStream); + var clientProxy = JsonRpc.Attach(clientStream); try { @@ -89,7 +89,7 @@ public async Task ServerRpc_Completion_ResolvesWhenClientDisconnects() var (serverStream, clientStream) = FullDuplexStream.CreatePair(); var rpcTarget = new DaemonRpcTarget(_service); using var serverRpc = JsonRpc.Attach(serverStream, rpcTarget); - var clientProxy = JsonRpc.Attach(clientStream); + var clientProxy = JsonRpc.Attach(clientStream); // Act — dispose client (simulates CLI process exit) ((IDisposable)clientProxy).Dispose(); @@ -111,7 +111,7 @@ public async Task ProcessCommandAsync_NullCommand_PropagatesAsRemoteException() var (serverStream, clientStream) = FullDuplexStream.CreatePair(); var rpcTarget = new DaemonRpcTarget(_service); using var serverRpc = JsonRpc.Attach(serverStream, rpcTarget); - var clientProxy = JsonRpc.Attach(clientStream); + var clientProxy = JsonRpc.Attach(clientStream); try { @@ -119,7 +119,7 @@ public async Task ProcessCommandAsync_NullCommand_PropagatesAsRemoteException() var request = new ServiceRequest { Command = null! }; #pragma warning restore CS8714 - // Act — ExcelMcpService.ProcessAsync catches NullReferenceException internally + // Act — PptMcpService.ProcessAsync catches NullReferenceException internally // and returns a ServiceResponse with Success=false (not an exception). var response = await clientProxy.ProcessCommandAsync(request); @@ -146,7 +146,7 @@ public async Task ProcessCommandAsync_InvalidSessionId_ReturnsStructuredError() var (serverStream, clientStream) = FullDuplexStream.CreatePair(); var rpcTarget = new DaemonRpcTarget(_service); using var serverRpc = JsonRpc.Attach(serverStream, rpcTarget); - var clientProxy = JsonRpc.Attach(clientStream); + var clientProxy = JsonRpc.Attach(clientStream); try { diff --git a/tests/ExcelMcp.CLI.Tests/xunit.runner.json b/tests/PptMcp.CLI.Tests/xunit.runner.json similarity index 100% rename from tests/ExcelMcp.CLI.Tests/xunit.runner.json rename to tests/PptMcp.CLI.Tests/xunit.runner.json diff --git a/tests/ExcelMcp.ComInterop.Tests/Integration/Session/DisposalVerificationTests.cs b/tests/PptMcp.ComInterop.Tests/Integration/Session/DisposalVerificationTests.cs similarity index 90% rename from tests/ExcelMcp.ComInterop.Tests/Integration/Session/DisposalVerificationTests.cs rename to tests/PptMcp.ComInterop.Tests/Integration/Session/DisposalVerificationTests.cs index 330f892e..9b4c3c77 100644 --- a/tests/ExcelMcp.ComInterop.Tests/Integration/Session/DisposalVerificationTests.cs +++ b/tests/PptMcp.ComInterop.Tests/Integration/Session/DisposalVerificationTests.cs @@ -1,10 +1,10 @@ using System.Diagnostics; using Microsoft.Extensions.Logging; -using Sbroenne.ExcelMcp.ComInterop.Session; +using PptMcp.ComInterop.Session; using Xunit; using Xunit.Abstractions; -namespace Sbroenne.ExcelMcp.ComInterop.Tests.Integration; +namespace PptMcp.ComInterop.Tests.Integration; /// /// Verifies that the Interlocked disposal fix prevents double disposal. @@ -14,7 +14,7 @@ namespace Sbroenne.ExcelMcp.ComInterop.Tests.Integration; [Trait("Speed", "Medium")] [Trait("Layer", "ComInterop")] [Trait("Feature", "SessionManager")] -[Trait("RequiresExcel", "true")] +[Trait("RequiresPowerPoint", "true")] [Collection("Sequential")] public class DisposalVerificationTest : IAsyncLifetime { @@ -31,13 +31,13 @@ public DisposalVerificationTest(ITestOutputHelper output) public Task InitializeAsync() { - // Kill any existing Excel processes to ensure clean state + // Kill any existing PowerPoint processes to ensure clean state try { - var existingProcesses = Process.GetProcessesByName("EXCEL"); + var existingProcesses = Process.GetProcessesByName("POWERPNT"); if (existingProcesses.Length > 0) { - _output.WriteLine($"Cleaning up {existingProcesses.Length} existing Excel processes..."); + _output.WriteLine($"Cleaning up {existingProcesses.Length} existing PowerPoint processes..."); foreach (var p in existingProcesses) { p.Kill(entireProcessTree: true); @@ -48,7 +48,7 @@ public Task InitializeAsync() } catch (Exception ex) { - _output.WriteLine($"Warning: Failed to clean Excel processes: {ex.Message}"); + _output.WriteLine($"Warning: Failed to clean PowerPoint processes: {ex.Message}"); } return Task.CompletedTask; @@ -88,18 +88,18 @@ public Task DisposeAsync() /// /// Path to the template xlsx file used for fast test file creation. - /// Copying a template is ~1000x faster than spawning Excel to create a new workbook. + /// Copying a template is ~1000x faster than spawning PowerPoint to create a new presentation. /// private static readonly string TemplateFilePath = Path.Combine( Path.GetDirectoryName(typeof(DisposalVerificationTest).Assembly.Location)!, - "Integration", "Session", "TestFiles", "batch-test-static.xlsx"); + "Integration", "Session", "TestFiles", "batch-test-static.pptx"); private string CreateTestFile(string testName) { - var fileName = $"{testName}_{Guid.NewGuid():N}.xlsx"; + var fileName = $"{testName}_{Guid.NewGuid():N}.pptx"; var filePath = Path.Combine(_tempDir, fileName); - // PERFORMANCE OPTIMIZATION: Copy from template instead of spawning Excel. + // PERFORMANCE OPTIMIZATION: Copy from template instead of spawning PowerPoint. // This reduces test file creation from ~7-14 seconds to <10ms. File.Copy(TemplateFilePath, filePath); @@ -118,10 +118,10 @@ public void Dispose_CalledTwice_OnlyDisposesOnce() builder.AddProvider(new TestLoggerProvider(_output)); builder.SetMinimumLevel(LogLevel.Debug); }); - var logger = loggerFactory.CreateLogger(); + var logger = loggerFactory.CreateLogger(); // Create batch with logger - var batch = new ExcelBatch(new[] { testFile }, logger); + var batch = new PptBatch(new[] { testFile }, logger); // First disposal - should execute _output.WriteLine("=== First DisposeAsync call ==="); diff --git a/tests/ExcelMcp.ComInterop.Tests/Integration/Session/ExcelBatchMessagePumpTests.cs b/tests/PptMcp.ComInterop.Tests/Integration/Session/PptBatchMessagePumpTests.cs similarity index 88% rename from tests/ExcelMcp.ComInterop.Tests/Integration/Session/ExcelBatchMessagePumpTests.cs rename to tests/PptMcp.ComInterop.Tests/Integration/Session/PptBatchMessagePumpTests.cs index 731edbe6..adaebb49 100644 --- a/tests/ExcelMcp.ComInterop.Tests/Integration/Session/ExcelBatchMessagePumpTests.cs +++ b/tests/PptMcp.ComInterop.Tests/Integration/Session/PptBatchMessagePumpTests.cs @@ -1,12 +1,12 @@ using System.Diagnostics; -using Sbroenne.ExcelMcp.ComInterop.Session; +using PptMcp.ComInterop.Session; using Xunit; using Xunit.Abstractions; -namespace Sbroenne.ExcelMcp.ComInterop.Tests.Integration.Session; +namespace PptMcp.ComInterop.Tests.Integration.Session; /// -/// Regression tests for the ExcelBatch message pump. +/// Regression tests for the PptBatch message pump. /// /// These tests validate the fix for a critical bug where the STA thread message pump /// degenerated into 100% CPU spin when idle. The bug had two independent mechanisms: @@ -15,7 +15,7 @@ namespace Sbroenne.ExcelMcp.ComInterop.Tests.Integration.Session; /// when any exception occurred (e.g., ObjectDisposedException on CancellationTokenSource). /// /// 2. Thread.Sleep(10) on STA thread with registered OLE message filter returned immediately -/// when pending COM messages existed (Excel events during calculation), turning the poll +/// when pending COM messages existed (PowerPoint events during calculation), turning the poll /// loop into a tight spin. /// /// The fix replaced polling with WaitToReadAsync() which blocks efficiently and wakes @@ -27,22 +27,22 @@ namespace Sbroenne.ExcelMcp.ComInterop.Tests.Integration.Session; /// - ✅ Test shutdown drains remaining work items (race condition fix) /// - ✅ Test Dispose during Execute gives clean error (race condition fix) /// -/// IMPORTANT: These tests spawn and terminate Excel processes (side effects). +/// IMPORTANT: These tests spawn and terminate PowerPoint processes (side effects). /// They run OnDemand only to avoid interference with normal test runs. /// [Trait("Category", "Integration")] [Trait("Speed", "Slow")] [Trait("Layer", "ComInterop")] -[Trait("Feature", "ExcelBatch")] +[Trait("Feature", "PptBatch")] [Trait("RunType", "OnDemand")] [Collection("Sequential")] // Disable parallelization to avoid COM interference -public class ExcelBatchMessagePumpTests : IAsyncLifetime +public class PptBatchMessagePumpTests : IAsyncLifetime { private readonly ITestOutputHelper _output; private static string? _staticTestFile; private string? _testFileCopy; - public ExcelBatchMessagePumpTests(ITestOutputHelper output) + public PptBatchMessagePumpTests(ITestOutputHelper output) { _output = output; } @@ -52,16 +52,16 @@ public Task InitializeAsync() if (_staticTestFile == null) { var testFolder = Path.Join(AppContext.BaseDirectory, "Integration", "Session", "TestFiles"); - _staticTestFile = Path.Join(testFolder, "batch-test-static.xlsx"); + _staticTestFile = Path.Join(testFolder, "batch-test-static.pptx"); if (!File.Exists(_staticTestFile)) { throw new FileNotFoundException($"Static test file not found at {_staticTestFile}. " + - "Please create the batch-test-static.xlsx file in the TestFiles folder."); + "Please create the batch-test-static.pptx file in the TestFiles folder."); } } - _testFileCopy = Path.Join(Path.GetTempPath(), $"batch-pump-test-{Guid.NewGuid():N}.xlsx"); + _testFileCopy = Path.Join(Path.GetTempPath(), $"batch-pump-test-{Guid.NewGuid():N}.pptx"); File.Copy(_staticTestFile, _testFileCopy, overwrite: true); return Task.Delay(500); @@ -86,25 +86,25 @@ public Task DisposeAsync() /// 3-second idle period. The original bug caused ~100% CPU on one core (2.97s/3s). /// The fix should show near-zero CPU. /// - /// We measure CPU time of the EXCEL.EXE process (which should be ~0 since we're not + /// We measure CPU time of the POWERPNT.EXE process (which should be ~0 since we're not /// doing anything) AND the overall thread behavior by measuring our own process's CPU. /// [Fact] public void MessagePump_WhenIdle_DoesNotSpinCpu() { // Arrange - using var batch = ExcelSession.BeginBatch(_testFileCopy!); + using var batch = PptSession.BeginBatch(_testFileCopy!); // Perform one operation to ensure everything is fully initialized batch.Execute((ctx, ct) => { - _ = ctx.Book.Worksheets[1]; + _ = ctx.Presentation.Slides.Count; return 0; }); - // Capture the Excel process ID for measurement - int? excelPid = batch.ExcelProcessId; - Assert.NotNull(excelPid); + // Capture the PowerPoint process ID for measurement + int? pptPid = batch.PowerPointProcessId; + Assert.NotNull(pptPid); // Let everything settle Thread.Sleep(500); @@ -114,12 +114,12 @@ public void MessagePump_WhenIdle_DoesNotSpinCpu() var cpuBefore = currentProcess.TotalProcessorTime; var wallBefore = Stopwatch.GetTimestamp(); - // Also measure Excel's CPU - TimeSpan excelCpuBefore; - using (var excelProcess = Process.GetProcessById(excelPid.Value)) + // Also measure PowerPoint's CPU + TimeSpan pptCpuBefore; + using (var pptProcess = Process.GetProcessById(pptPid.Value)) { - excelProcess.Refresh(); - excelCpuBefore = excelProcess.TotalProcessorTime; + pptProcess.Refresh(); + pptCpuBefore = pptProcess.TotalProcessorTime; } // Idle period — the message pump should be sleeping, not spinning @@ -128,11 +128,11 @@ public void MessagePump_WhenIdle_DoesNotSpinCpu() var cpuAfter = currentProcess.TotalProcessorTime; var wallAfter = Stopwatch.GetTimestamp(); - TimeSpan excelCpuAfter; - using (var excelProcess = Process.GetProcessById(excelPid.Value)) + TimeSpan pptCpuAfter; + using (var pptProcess = Process.GetProcessById(pptPid.Value)) { - excelProcess.Refresh(); - excelCpuAfter = excelProcess.TotalProcessorTime; + pptProcess.Refresh(); + pptCpuAfter = pptProcess.TotalProcessorTime; } // Calculate @@ -140,12 +140,12 @@ public void MessagePump_WhenIdle_DoesNotSpinCpu() var wallElapsed = Stopwatch.GetElapsedTime(wallBefore, wallAfter).TotalMilliseconds; var cpuPercent = (cpuUsed / wallElapsed) * 100.0; - var excelCpuUsed = (excelCpuAfter - excelCpuBefore).TotalMilliseconds; - var excelCpuPercent = (excelCpuUsed / wallElapsed) * 100.0; + var pptCpuUsed = (pptCpuAfter - pptCpuBefore).TotalMilliseconds; + var pptCpuPercent = (pptCpuUsed / wallElapsed) * 100.0; _output.WriteLine($"Idle period: {wallElapsed:F0}ms wall time"); _output.WriteLine($"MCP process CPU: {cpuUsed:F1}ms ({cpuPercent:F1}%)"); - _output.WriteLine($"Excel process CPU: {excelCpuUsed:F1}ms ({excelCpuPercent:F1}%)"); + _output.WriteLine($"PowerPoint process CPU: {pptCpuUsed:F1}ms ({pptCpuPercent:F1}%)"); // Assert — CPU should be well under 5% during idle. // The original bug showed ~100% (one full core). Even with test runner overhead, @@ -171,12 +171,12 @@ public void MessagePump_WhenIdle_DoesNotSpinCpu() public void MessagePump_WhenWorkArrives_WakesWithLowLatency() { // Arrange - using var batch = ExcelSession.BeginBatch(_testFileCopy!); + using var batch = PptSession.BeginBatch(_testFileCopy!); // Warmup — ensure first-call JIT overhead is gone batch.Execute((ctx, ct) => { - _ = ctx.Book.Worksheets[1]; + _ = ctx.Presentation.Slides.Count; return 0; }); @@ -239,12 +239,12 @@ public void MessagePump_WhenWorkArrives_WakesWithLowLatency() public void Dispose_WithPendingWork_DrainsBeforeExiting() { // Arrange - var batch = ExcelSession.BeginBatch(_testFileCopy!); + var batch = PptSession.BeginBatch(_testFileCopy!); // Initialize batch.Execute((ctx, ct) => { - _ = ctx.Book.Worksheets[1]; + _ = ctx.Presentation.Slides.Count; return 0; }); @@ -336,11 +336,11 @@ public void Dispose_WithPendingWork_DrainsBeforeExiting() public void Execute_AfterDispose_ThrowsObjectDisposedException() { // Arrange — create and immediately dispose - var batch = ExcelSession.BeginBatch(_testFileCopy!); + var batch = PptSession.BeginBatch(_testFileCopy!); batch.Execute((ctx, ct) => { - _ = ctx.Book.Worksheets[1]; + _ = ctx.Presentation.Slides.Count; return 0; }); @@ -365,7 +365,7 @@ public void Execute_AfterDispose_ThrowsObjectDisposedException() /// actively waiting for its result. The Execute caller should get either: /// - Their result (if work completed before disposal) /// - ObjectDisposedException (if disposal won the race) - /// - TimeoutException (if Excel cleanup took too long — unlikely but acceptable) + /// - TimeoutException (if PowerPoint cleanup took too long — unlikely but acceptable) /// /// It must NOT get a ChannelClosedException or hang indefinitely. /// @@ -373,12 +373,12 @@ public void Execute_AfterDispose_ThrowsObjectDisposedException() public void Dispose_DuringActiveExecute_GivesCleanError() { // Arrange - var batch = ExcelSession.BeginBatch(_testFileCopy!); + var batch = PptSession.BeginBatch(_testFileCopy!); // Initialize batch.Execute((ctx, ct) => { - _ = ctx.Book.Worksheets[1]; + _ = ctx.Presentation.Slides.Count; return 0; }); diff --git a/tests/PptMcp.ComInterop.Tests/Integration/Session/PptBatchTests.cs b/tests/PptMcp.ComInterop.Tests/Integration/Session/PptBatchTests.cs new file mode 100644 index 00000000..ee2c5a4f --- /dev/null +++ b/tests/PptMcp.ComInterop.Tests/Integration/Session/PptBatchTests.cs @@ -0,0 +1,374 @@ +using System.Diagnostics; +using PptMcp.ComInterop.Session; +using Xunit; +using Xunit.Abstractions; + +namespace PptMcp.ComInterop.Tests.Integration.Session; + +/// +/// Integration tests for PptBatch - verifies batch operations and COM cleanup. +/// Tests that PowerPoint instances are reused across operations and properly cleaned up. +/// +/// LAYER RESPONSIBILITY: +/// - ✅ Test PptBatch.Execute() reuses PowerPoint instance +/// - ✅ Test PptBatch.Dispose() COM cleanup +/// - ✅ Test PptBatch.Save() functionality +/// - ✅ Verify POWERPNT.EXE process termination (no leaks) +/// +/// NOTE: PptBatch.Dispose() handles all GC cleanup automatically. +/// Tests only need to wait for async disposal and process termination timing. +/// +/// IMPORTANT: These tests spawn and terminate PowerPoint processes (side effects). +/// They run OnDemand only to avoid interference with normal test runs. +/// +[Trait("Category", "Integration")] +[Trait("Speed", "Slow")] +[Trait("Layer", "ComInterop")] +[Trait("Feature", "PptBatch")] +[Collection("Sequential")] // Disable parallelization to avoid COM interference +public class PptBatchTests : IAsyncLifetime +{ + private readonly ITestOutputHelper _output; + private static string? _staticTestFile; + private string? _testFileCopy; + + public PptBatchTests(ITestOutputHelper output) + { + _output = output; + } + + public Task InitializeAsync() + { + // Use static test file from TestFiles folder (must be pre-created) + if (_staticTestFile == null) + { + var testFolder = Path.Join(AppContext.BaseDirectory, "Integration", "Session", "TestFiles"); + _staticTestFile = Path.Join(testFolder, "batch-test-static.pptx"); + + // Verify the static file exists + if (!File.Exists(_staticTestFile)) + { + throw new FileNotFoundException($"Static test file not found at {_staticTestFile}. " + + "Please create the batch-test-static.pptx file in the TestFiles folder."); + } + } + + // Create a fresh copy for this test instance (in temp folder) + _testFileCopy = Path.Join(Path.GetTempPath(), $"batch-test-{Guid.NewGuid():N}.pptx"); + File.Copy(_staticTestFile, _testFileCopy, overwrite: true); + + // Wait for any PowerPoint processes from file creation to terminate + return Task.Delay(500); + } + + public Task DisposeAsync() + { + // Clean up this test's copy + if (_testFileCopy != null && File.Exists(_testFileCopy)) + { + File.Delete(_testFileCopy); + } + return Task.CompletedTask; + } + + private static void CleanupStaticFile() + { + if (_staticTestFile != null && File.Exists(_staticTestFile)) + { + File.Delete(_staticTestFile); + } + } + + [Fact] + public void ExecuteAsync_MultipleOperations_ReusesPowerPointInstance() + { + // Arrange + int operationCount = 0; + + // Act - Use batching for multiple operations + using var batch = PptSession.BeginBatch(_testFileCopy!); + + for (int i = 0; i < 5; i++) + { + batch.Execute((ctx, ct) => + { + operationCount++; + _output.WriteLine($"Batch operation {operationCount}"); + + // Verify we have the same context + Assert.NotNull(ctx.App); + Assert.NotNull(ctx.Presentation); + + return operationCount; + }); + } + + // Assert + Assert.Equal(5, operationCount); + _output.WriteLine($"✓ Completed {operationCount} batch operations"); + } + + [Fact] + public void Dispose_CleansUpComObjects_NoProcessLeak() + { + // Arrange + var startingProcesses = Process.GetProcessesByName("POWERPNT"); + int startingCount = startingProcesses.Length; + + _output.WriteLine($"PowerPoint processes before: {startingCount}"); + + // Act + var batch = PptSession.BeginBatch(_testFileCopy!); + + batch.Execute((ctx, ct) => + { + // Access slide count to verify COM is working + int slideCount = ctx.Presentation.Slides.Count; + _output.WriteLine($"Slide count: {slideCount}"); + return 0; + }); + + batch.Dispose(); + + // Wait for PowerPoint process to fully terminate with polling + // PowerPoint.Quit() signals shutdown but process termination is OS-controlled + // Dispose() blocks up to StaThreadJoinTimeout for COM cleanup, but process may linger briefly + var waitTimeout = TimeSpan.FromSeconds(15); + var stopwatch = Stopwatch.StartNew(); + int endingCount; + do + { + Thread.Sleep(500); + endingCount = Process.GetProcessesByName("POWERPNT").Length; + _output.WriteLine($"PowerPoint processes at {stopwatch.Elapsed.TotalSeconds:F1}s: {endingCount}"); + } + while (endingCount > startingCount && stopwatch.Elapsed < waitTimeout); + + // Assert + _output.WriteLine($"PowerPoint processes after {stopwatch.Elapsed.TotalSeconds:F1}s: {endingCount}"); + + Assert.True(endingCount <= startingCount, + $"PowerPoint process leak in batch! Started with {startingCount}, ended with {endingCount} after {waitTimeout.TotalSeconds}s"); + } + + [Fact] + public void Save_PersistsChanges_ToPresentation() + { + // Arrange + string testValue = $"Test-{Guid.NewGuid():N}"; + + // Act - Write and save + using (var batch = PptSession.BeginBatch(_testFileCopy!)) + { + batch.Execute((ctx, ct) => + { + // Add a slide and set its title text + dynamic slide = ctx.Presentation.Slides[1]; + dynamic shape = slide.Shapes[1]; + shape.TextFrame.TextRange.Text = testValue; + return 0; + }); + + batch.Save(); + } + + // Wait for file to be released + Thread.Sleep(1000); + + // Verify - Read back the value in a new batch session + string readValue; + using (var batch = PptSession.BeginBatch(_testFileCopy!)) + { + readValue = batch.Execute((ctx, ct) => + { + dynamic slide = ctx.Presentation.Slides[1]; + dynamic shape = slide.Shapes[1]; + string result = shape.TextFrame.TextRange.Text?.ToString() ?? ""; + return result; + }); + } + + // Assert + Assert.Equal(testValue, readValue); + _output.WriteLine($"✓ Value persisted correctly: {testValue}"); + } + + [Fact] + public void PresentationPath_ReturnsCorrectPath() + { + // Arrange & Act + using var batch = PptSession.BeginBatch(_testFileCopy!); + + // Assert + Assert.Equal(_testFileCopy, batch.PresentationPath); + } + + [Fact] + public void CompleteWorkflow_CreateModifyReadSave_AllOperationsSucceed() + { + // Arrange + string testBody = "Test Body Content"; + + // Act - Execute complete workflow in single batch + using (var batch = PptSession.BeginBatch(_testFileCopy!)) + { + int initialSlideCount = 0; + + // Step 1: Get initial slide count + batch.Execute((ctx, ct) => + { + initialSlideCount = ctx.Presentation.Slides.Count; + _output.WriteLine($"✓ Initial slide count: {initialSlideCount}"); + return 0; + }); + + // Step 2: Add a new slide + batch.Execute((ctx, ct) => + { + dynamic pres = ctx.Presentation; + // Use layout from first slide master + dynamic layout = pres.SlideMaster.CustomLayouts[1]; + pres.Slides.AddSlide(pres.Slides.Count + 1, layout); + _output.WriteLine("✓ Added new slide"); + return 0; + }); + + // Step 3: Write text to the new slide + batch.Execute((ctx, ct) => + { + dynamic slide = ctx.Presentation.Slides[ctx.Presentation.Slides.Count]; + // Add a text box shape + dynamic shape = slide.Shapes.AddTextbox(1, 100, 100, 400, 200); // msoTextOrientationHorizontal=1 + shape.TextFrame.TextRange.Text = testBody; + _output.WriteLine($"✓ Wrote text to slide: {testBody}"); + return 0; + }); + + // Step 4: Read back to verify + var readData = batch.Execute((ctx, ct) => + { + int currentCount = ctx.Presentation.Slides.Count; + dynamic lastSlide = ctx.Presentation.Slides[currentCount]; + string text = ""; + for (int i = 1; i <= lastSlide.Shapes.Count; i++) + { + dynamic shape = lastSlide.Shapes[i]; + if (Convert.ToInt32(shape.HasTextFrame) != 0) + { + text = shape.TextFrame.TextRange.Text?.ToString() ?? ""; + if (text.Length > 0) break; + } + } + _output.WriteLine($"✓ Read back: slideCount={currentCount}, text={text}"); + return (currentCount, text); + }); + + // Verify + Assert.True(readData.currentCount > initialSlideCount, "Should have more slides after add"); + Assert.Equal(testBody, readData.text); + + // Step 5: Save + batch.Save(); + _output.WriteLine("✓ Saved presentation"); + } + + // Wait for file to be released + Thread.Sleep(1000); + + // Verify - Open in new batch and check changes persisted + using (var batch = PptSession.BeginBatch(_testFileCopy!)) + { + var verifyData = batch.Execute((ctx, ct) => + { + int slideCount = ctx.Presentation.Slides.Count; + dynamic lastSlide = ctx.Presentation.Slides[slideCount]; + string text = ""; + for (int i = 1; i <= lastSlide.Shapes.Count; i++) + { + dynamic shape = lastSlide.Shapes[i]; + if (Convert.ToInt32(shape.HasTextFrame) != 0) + { + text = shape.TextFrame.TextRange.Text?.ToString() ?? ""; + if (text.Length > 0) break; + } + } + return (slideCount, text); + }); + + Assert.True(verifyData.slideCount >= 2, "Should have at least 2 slides after save"); + Assert.Equal(testBody, verifyData.text); + _output.WriteLine("✓ All workflow changes persisted correctly"); + } + } + + // NOTE: ParallelBatches test removed — PowerPoint COM is single-instance. + // Creating multiple PowerPoint.Application objects shares the same POWERPNT.EXE process. + // Multi-session is not supported. + + [Fact] + [Trait("Category", "Integration")] + [Trait("Feature", "FileLocking")] + public void Constructor_FileLockedByAnotherProcess_ThrowsInvalidOperationException() + { + // Arrange - Create a separate test file for locking test + var lockedTestFile = Path.Join(Path.GetTempPath(), $"batch-test-locked-{Guid.NewGuid():N}.pptx"); + File.Copy(_staticTestFile!, lockedTestFile, overwrite: true); + + try + { + // Lock the file by opening with exclusive access (simulating PowerPoint or another process) + using var fileLock = new FileStream( + lockedTestFile, + FileMode.Open, + FileAccess.ReadWrite, + FileShare.None); + + // Act & Assert - Attempting to create PptBatch should fail immediately + var ex = Assert.Throws(() => + { + var batch = PptSession.BeginBatch(lockedTestFile); + batch.Dispose(); + }); + + // Verify error message is clear and actionable + Assert.Contains("already open", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("close the file", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("exclusive access", ex.Message, StringComparison.OrdinalIgnoreCase); + + _output.WriteLine($"✓ File locking detected successfully"); + _output.WriteLine($"Error message: {ex.Message}"); + } + finally + { + // Cleanup + if (File.Exists(lockedTestFile)) + { +#pragma warning disable CA1031 // Intentional: best-effort test cleanup + try { File.Delete(lockedTestFile); } catch (Exception) { /* Best effort - file may be locked */ } +#pragma warning restore CA1031 + } + } + } + + // Note: Testing file-already-open scenario is complex because: + // 1. PowerPoint's behavior when opening an already-open file can vary (hang, prompt, or succeed) + // 2. The error detection code in PptBatch.cs catches COM Error 0x800A03EC + // 3. This test would require simulating PowerPoint having the file open externally + // + // The error handling code is verified through: + // - Manual testing: Open file in PowerPoint UI, then try automation + // - Real-world usage: Users will encounter this if they forget to close files + // - Code review: Error message is clear and actionable + // + // UPDATE: We now have a test (Constructor_FileLockedByAnotherProcess_ThrowsInvalidOperationException) + // that verifies the OS-level file locking check without requiring PowerPoint to be running. + // + // Keeping this comment as documentation that the scenario is handled in production code. +} + + + + + + + diff --git a/tests/ExcelMcp.ComInterop.Tests/Integration/Session/ExcelBatchTimeoutTests.cs b/tests/PptMcp.ComInterop.Tests/Integration/Session/PptBatchTimeoutTests.cs similarity index 77% rename from tests/ExcelMcp.ComInterop.Tests/Integration/Session/ExcelBatchTimeoutTests.cs rename to tests/PptMcp.ComInterop.Tests/Integration/Session/PptBatchTimeoutTests.cs index 79850baa..53325e8c 100644 --- a/tests/ExcelMcp.ComInterop.Tests/Integration/Session/ExcelBatchTimeoutTests.cs +++ b/tests/PptMcp.ComInterop.Tests/Integration/Session/PptBatchTimeoutTests.cs @@ -1,39 +1,39 @@ using System.Diagnostics; -using Sbroenne.ExcelMcp.ComInterop.Session; +using PptMcp.ComInterop.Session; using Xunit; using Xunit.Abstractions; -namespace Sbroenne.ExcelMcp.ComInterop.Tests.Integration.Session; +namespace PptMcp.ComInterop.Tests.Integration.Session; /// -/// Tests for the operation timeout → force-kill → cleanup chain in ExcelBatch. +/// Tests for the operation timeout → force-kill → cleanup chain in PptBatch. /// /// These tests validate the fix for Bug 8 (Feb 2026) where a stuck IDispatch.Invoke /// caused the MCP server to hang permanently because: -/// 1. No timeout recovery existed — ExcelBatch.Dispose() waited forever on STA thread join -/// 2. No pre-emptive kill — Excel process was never killed when operations timed out +/// 1. No timeout recovery existed — PptBatch.Dispose() waited forever on STA thread join +/// 2. No pre-emptive kill — PowerPoint process was never killed when operations timed out /// 3. No session cleanup — WithSessionAsync didn't handle TimeoutException /// /// LAYER RESPONSIBILITY: /// - ✅ Test that Execute() throws TimeoutException when operation exceeds timeout /// - ✅ Test that _operationTimedOut triggers pre-emptive Process.Kill() in Dispose() /// - ✅ Test that Dispose() completes (doesn't hang) after timeout -/// - ✅ Test that Excel process is cleaned up after timeout + dispose +/// - ✅ Test that PowerPoint process is cleaned up after timeout + dispose /// - ✅ Test that cancelled operations also trigger cleanup /// [Trait("Category", "Integration")] [Trait("Speed", "Slow")] [Trait("Layer", "ComInterop")] -[Trait("Feature", "ExcelBatch")] +[Trait("Feature", "PptBatch")] [Trait("RunType", "OnDemand")] [Collection("Sequential")] -public class ExcelBatchTimeoutTests : IAsyncLifetime +public class PptBatchTimeoutTests : IAsyncLifetime { private readonly ITestOutputHelper _output; private static string? _staticTestFile; private string? _testFileCopy; - public ExcelBatchTimeoutTests(ITestOutputHelper output) + public PptBatchTimeoutTests(ITestOutputHelper output) { _output = output; } @@ -43,7 +43,7 @@ public Task InitializeAsync() if (_staticTestFile == null) { var testFolder = Path.Join(AppContext.BaseDirectory, "Integration", "Session", "TestFiles"); - _staticTestFile = Path.Join(testFolder, "batch-test-static.xlsx"); + _staticTestFile = Path.Join(testFolder, "batch-test-static.pptx"); if (!File.Exists(_staticTestFile)) { @@ -51,7 +51,7 @@ public Task InitializeAsync() } } - _testFileCopy = Path.Join(Path.GetTempPath(), $"batch-timeout-test-{Guid.NewGuid():N}.xlsx"); + _testFileCopy = Path.Join(Path.GetTempPath(), $"batch-timeout-test-{Guid.NewGuid():N}.pptx"); File.Copy(_staticTestFile, _testFileCopy, overwrite: true); return Task.Delay(500); @@ -77,19 +77,19 @@ public Task DisposeAsync() public void Execute_OperationExceedsTimeout_ThrowsTimeoutException() { // Arrange — use a very short timeout (3 seconds) to trigger timeout quickly - var batch = ExcelSession.BeginBatch( + var batch = PptSession.BeginBatch( show: false, operationTimeout: TimeSpan.FromSeconds(3), _testFileCopy!); - // Warm up — ensure Excel is ready + // Warm up — ensure PowerPoint is ready batch.Execute((ctx, ct) => { - _ = ctx.Book.Worksheets[1]; + _ = ctx.Presentation.Slides.Count; return 0; }); - _output.WriteLine("Excel initialized, starting long-running operation..."); + _output.WriteLine("PowerPoint initialized, starting long-running operation..."); // Act & Assert — operation that exceeds timeout must throw TimeoutException var sw = Stopwatch.StartNew(); @@ -122,28 +122,28 @@ public void Execute_OperationExceedsTimeout_ThrowsTimeoutException() } /// - /// REGRESSION TEST: After timeout, the Excel process must be killed and cleaned up. - /// Before Bug 8 fix, the hung Excel process would remain alive permanently. + /// REGRESSION TEST: After timeout, the PowerPoint process must be killed and cleaned up. + /// Before Bug 8 fix, the hung PowerPoint process would remain alive permanently. /// [Fact] - public void Execute_AfterTimeout_ExcelProcessIsCleaned() + public void Execute_AfterTimeout_PowerPointProcessIsCleaned() { // Arrange - var startingProcesses = Process.GetProcessesByName("EXCEL"); + var startingProcesses = Process.GetProcessesByName("POWERPNT"); int startingCount = startingProcesses.Length; - _output.WriteLine($"Excel processes before: {startingCount}"); + _output.WriteLine($"PowerPoint processes before: {startingCount}"); - var batch = ExcelSession.BeginBatch( + var batch = PptSession.BeginBatch( show: false, operationTimeout: TimeSpan.FromSeconds(3), _testFileCopy!); - // Get the Excel process ID before timeout - int? excelPid = batch.ExcelProcessId; - _output.WriteLine($"Excel PID for this session: {excelPid}"); + // Get the PowerPoint process ID before timeout + int? pptPid = batch.PowerPointProcessId; + _output.WriteLine($"PowerPoint PID for this session: {pptPid}"); // Warm up - batch.Execute((ctx, ct) => { _ = ctx.Book.Worksheets[1]; return 0; }); + batch.Execute((ctx, ct) => { _ = ctx.Presentation.Slides.Count; return 0; }); // Act — trigger timeout Assert.Throws(() => @@ -161,13 +161,13 @@ public void Execute_AfterTimeout_ExcelProcessIsCleaned() // Wait briefly for process cleanup Thread.Sleep(2000); - // Assert — Excel process from this session should be gone - if (excelPid.HasValue) + // Assert — PowerPoint process from this session should be gone + if (pptPid.HasValue) { bool processAlive; try { - using var process = Process.GetProcessById(excelPid.Value); + using var process = Process.GetProcessById(pptPid.Value); processAlive = !process.HasExited; } catch (ArgumentException) @@ -176,17 +176,17 @@ public void Execute_AfterTimeout_ExcelProcessIsCleaned() } Assert.False(processAlive, - $"REGRESSION: Excel process {excelPid.Value} is still alive after timeout + dispose. " + + $"REGRESSION: PowerPoint process {pptPid.Value} is still alive after timeout + dispose. " + "Pre-emptive kill in Dispose() may not be working."); - _output.WriteLine($"✓ Excel process {excelPid.Value} was cleaned up after timeout"); + _output.WriteLine($"✓ PowerPoint process {pptPid.Value} was cleaned up after timeout"); } // Also check total count hasn't leaked - int endingCount = Process.GetProcessesByName("EXCEL").Length; - _output.WriteLine($"Excel processes after: {endingCount}"); + int endingCount = Process.GetProcessesByName("POWERPNT").Length; + _output.WriteLine($"PowerPoint processes after: {endingCount}"); Assert.True(endingCount <= startingCount, - $"Excel process leak! Started with {startingCount}, ended with {endingCount}"); + $"PowerPoint process leak! Started with {startingCount}, ended with {endingCount}"); } /// @@ -198,12 +198,12 @@ public void Execute_AfterTimeout_ExcelProcessIsCleaned() public void Dispose_AfterTimeout_CompletesWithinAggressiveTimeout() { // Arrange - var batch = ExcelSession.BeginBatch( + var batch = PptSession.BeginBatch( show: false, operationTimeout: TimeSpan.FromSeconds(3), _testFileCopy!); - batch.Execute((ctx, ct) => { _ = ctx.Book.Worksheets[1]; return 0; }); + batch.Execute((ctx, ct) => { _ = ctx.Presentation.Slides.Count; return 0; }); // Trigger timeout Assert.Throws(() => @@ -240,12 +240,12 @@ public void Dispose_AfterTimeout_CompletesWithinAggressiveTimeout() public void Execute_CallerCancellation_DisposeCleansUpQuickly() { // Arrange - var batch = ExcelSession.BeginBatch( + var batch = PptSession.BeginBatch( show: false, operationTimeout: TimeSpan.FromMinutes(5), // Normal timeout — not the trigger _testFileCopy!); - batch.Execute((ctx, ct) => { _ = ctx.Book.Worksheets[1]; return 0; }); + batch.Execute((ctx, ct) => { _ = ctx.Presentation.Slides.Count; return 0; }); var cts = new CancellationTokenSource(); @@ -304,13 +304,13 @@ public void Execute_CallerCancellation_DisposeCleansUpQuickly() public void Execute_AfterPreviousTimeout_FailsFastWithTimeoutException() { // Arrange — short timeout to trigger the first timeout quickly - var batch = ExcelSession.BeginBatch( + var batch = PptSession.BeginBatch( show: false, operationTimeout: TimeSpan.FromSeconds(3), _testFileCopy!); // Warm up - batch.Execute((ctx, ct) => { _ = ctx.Book.Worksheets[1]; return 0; }); + batch.Execute((ctx, ct) => { _ = ctx.Presentation.Slides.Count; return 0; }); // Trigger timeout on first operation Assert.Throws(() => @@ -346,24 +346,24 @@ public void Execute_AfterPreviousTimeout_FailsFastWithTimeoutException() } /// - /// Verify that ExcelProcessId is captured during session creation. + /// Verify that PowerPointProcessId is captured during session creation. /// This is a prerequisite for the pre-emptive kill to work. /// [Fact] - public void BeginBatch_CapturesExcelProcessId() + public void BeginBatch_CapturesPowerPointProcessId() { // Arrange & Act - using var batch = ExcelSession.BeginBatch(_testFileCopy!); + using var batch = PptSession.BeginBatch(_testFileCopy!); // Assert - Assert.NotNull(batch.ExcelProcessId); - Assert.True(batch.ExcelProcessId > 0, "ExcelProcessId should be a valid PID"); + Assert.NotNull(batch.PowerPointProcessId); + Assert.True(batch.PowerPointProcessId > 0, "PowerPointProcessId should be a valid PID"); // Verify the process actually exists - using var process = Process.GetProcessById(batch.ExcelProcessId.Value); - Assert.False(process.HasExited, "Excel process should be running"); - Assert.Equal("EXCEL", process.ProcessName, ignoreCase: true); + using var process = Process.GetProcessById(batch.PowerPointProcessId.Value); + Assert.False(process.HasExited, "PowerPoint process should be running"); + Assert.Equal("POWERPNT", process.ProcessName, ignoreCase: true); - _output.WriteLine($"✓ ExcelProcessId captured: {batch.ExcelProcessId}"); + _output.WriteLine($"✓ PowerPointProcessId captured: {batch.PowerPointProcessId}"); } } diff --git a/tests/ExcelMcp.ComInterop.Tests/Integration/Session/ExcelSessionTests.cs b/tests/PptMcp.ComInterop.Tests/Integration/Session/PptSessionTests.cs similarity index 64% rename from tests/ExcelMcp.ComInterop.Tests/Integration/Session/ExcelSessionTests.cs rename to tests/PptMcp.ComInterop.Tests/Integration/Session/PptSessionTests.cs index c6d006a8..1952f623 100644 --- a/tests/ExcelMcp.ComInterop.Tests/Integration/Session/ExcelSessionTests.cs +++ b/tests/PptMcp.ComInterop.Tests/Integration/Session/PptSessionTests.cs @@ -1,46 +1,46 @@ using System.Diagnostics; -using Sbroenne.ExcelMcp.ComInterop.Session; +using PptMcp.ComInterop.Session; using Xunit; using Xunit.Abstractions; -namespace Sbroenne.ExcelMcp.ComInterop.Tests.Integration; +namespace PptMcp.ComInterop.Tests.Integration; /// -/// Integration tests for ExcelSession - verifies public API and COM cleanup. +/// Integration tests for PptSession - verifies public API and COM cleanup. /// Tests BeginBatch() and CreateNew() functionality. /// /// LAYER RESPONSIBILITY: -/// - ✅ Test ExcelSession.BeginBatch() validation and batch creation -/// - ✅ Test ExcelSession.CreateNew() file creation -/// - ✅ Verify Excel.exe process termination (no leaks) +/// - ✅ Test PptSession.BeginBatch() validation and batch creation +/// - ✅ Test PptSession.CreateNew() file creation +/// - ✅ Verify POWERPNT.EXE process termination (no leaks) /// -/// NOTE: ExcelSession methods use ExcelShutdownService for resilient cleanup. +/// NOTE: PptSession methods use PptShutdownService for resilient cleanup. /// Automatic RCW finalizers handle COM reference cleanup (no forced GC needed). /// Process cleanup errors are logged but don't fail tests. /// [Trait("Category", "Integration")] [Trait("Speed", "Slow")] [Trait("Layer", "ComInterop")] -[Trait("Feature", "ExcelSession")] +[Trait("Feature", "PptSession")] [Collection("Sequential")] // Disable parallelization to avoid COM interference -public class ExcelSessionTests : IDisposable +public class PptSessionTests : IDisposable { private readonly ITestOutputHelper _output; - public ExcelSessionTests(ITestOutputHelper output) + public PptSessionTests(ITestOutputHelper output) { _output = output; - // Kill any existing Excel processes to ensure clean state - var existingProcesses = Process.GetProcessesByName("EXCEL"); + // Kill any existing PowerPoint processes to ensure clean state + var existingProcesses = Process.GetProcessesByName("POWERPNT"); if (existingProcesses.Length > 0) { - _output.WriteLine($"Cleaning up {existingProcesses.Length} existing Excel processes..."); + _output.WriteLine($"Cleaning up {existingProcesses.Length} existing PowerPoint processes..."); foreach (var p in existingProcesses) { p.Kill(); p.WaitForExit(2000); } - _output.WriteLine("Excel processes cleaned up"); + _output.WriteLine("PowerPoint processes cleaned up"); } } @@ -58,17 +58,17 @@ public void Dispose() public void BeginBatch_WithValidFile_CreatesBatch() { // Arrange - string testFile = Path.Join(Path.GetTempPath(), $"session-test-{Guid.NewGuid():N}.xlsx"); + string testFile = Path.Join(Path.GetTempPath(), $"session-test-{Guid.NewGuid():N}.pptx"); CreateTempTestFile(testFile); try { // Act - using var batch = ExcelSession.BeginBatch(testFile); + using var batch = PptSession.BeginBatch(testFile); // Assert Assert.NotNull(batch); - Assert.Equal(testFile, batch.WorkbookPath); + Assert.Equal(testFile, batch.PresentationPath); _output.WriteLine($"✓ Batch created successfully for: {Path.GetFileName(testFile)}"); } @@ -82,12 +82,12 @@ public void BeginBatch_WithValidFile_CreatesBatch() public void BeginBatch_WithNonExistentFile_ThrowsFileNotFoundException() { // Arrange - string nonExistentFile = Path.Join(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.xlsx"); + string nonExistentFile = Path.Join(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.pptx"); // Act & Assert Assert.Throws(() => { - using var batch = ExcelSession.BeginBatch(nonExistentFile); + using var batch = PptSession.BeginBatch(nonExistentFile); }); _output.WriteLine("✓ Correctly throws FileNotFoundException for non-existent file"); @@ -105,11 +105,11 @@ public void BeginBatch_WithInvalidExtension_ThrowsArgumentException() // Act & Assert var exception = Assert.Throws(() => { - using var batch = ExcelSession.BeginBatch(invalidFile); + using var batch = PptSession.BeginBatch(invalidFile); }); Assert.Contains("Invalid file extension", exception.Message); - _output.WriteLine("✓ Correctly rejects non-Excel file extension"); + _output.WriteLine("✓ Correctly rejects non-PowerPoint file extension"); } finally { @@ -118,17 +118,17 @@ public void BeginBatch_WithInvalidExtension_ThrowsArgumentException() } [Fact] - public void CreateNew_CreatesNewWorkbook() + public void CreateNew_CreatesNewPresentation() { // Arrange - string testFile = Path.Join(Path.GetTempPath(), $"new-workbook-{Guid.NewGuid():N}.xlsx"); + string testFile = Path.Join(Path.GetTempPath(), $"new-presentation-{Guid.NewGuid():N}.pptx"); try { // Act - var result = ExcelSession.CreateNew(testFile, isMacroEnabled: false, (ctx, ct) => + var result = PptSession.CreateNew(testFile, isMacroEnabled: false, (ctx, ct) => { - _output.WriteLine($"✓ Workbook created at: {ctx.WorkbookPath}"); + _output.WriteLine($"✓ Presentation created at: {ctx.PresentationPath}"); return 0; }); @@ -137,12 +137,12 @@ public void CreateNew_CreatesNewWorkbook() Assert.Equal(0, result); // Verify we can open it with batch API - using (var batch = ExcelSession.BeginBatch(testFile)) + using (var batch = PptSession.BeginBatch(testFile)) { batch.Execute((ctx, ct) => { - Assert.NotNull(ctx.Book); - _output.WriteLine("✓ Can open created workbook with batch API"); + Assert.NotNull(ctx.Presentation); + _output.WriteLine("✓ Can open created presentation with batch API"); return 0; }); } @@ -157,21 +157,21 @@ public void CreateNew_CreatesNewWorkbook() public void CreateNew_WithMacroEnabled_CreatesXlsmFile() { // Arrange - string testFile = Path.Join(Path.GetTempPath(), $"new-macro-workbook-{Guid.NewGuid():N}.xlsm"); + string testFile = Path.Join(Path.GetTempPath(), $"new-macro-presentation-{Guid.NewGuid():N}.pptm"); try { // Act - var result = ExcelSession.CreateNew(testFile, isMacroEnabled: true, (ctx, ct) => + var result = PptSession.CreateNew(testFile, isMacroEnabled: true, (ctx, ct) => { - _output.WriteLine($"✓ Macro-enabled workbook created at: {ctx.WorkbookPath}"); + _output.WriteLine($"✓ Macro-enabled presentation created at: {ctx.PresentationPath}"); return 0; }); // Assert Assert.True(File.Exists(testFile), "XLSM file should be created"); - Assert.Equal(".xlsm", Path.GetExtension(testFile).ToLowerInvariant()); - _output.WriteLine("✓ Correctly created .xlsm file"); + Assert.Equal(".pptm", Path.GetExtension(testFile).ToLowerInvariant()); + _output.WriteLine("✓ Correctly created .pptm file"); } finally { @@ -184,12 +184,12 @@ public void CreateNew_CreatesDirectoryIfNeeded() { // Arrange string testDir = Path.Join(Path.GetTempPath(), $"testdir-{Guid.NewGuid():N}"); - string testFile = Path.Join(testDir, "newfile.xlsx"); + string testFile = Path.Join(testDir, "newfile.pptx"); try { // Act - ExcelSession.CreateNew(testFile, isMacroEnabled: false, (ctx, ct) => + PptSession.CreateNew(testFile, isMacroEnabled: false, (ctx, ct) => { return 0; }); @@ -209,16 +209,16 @@ public void CreateNew_CreatesDirectoryIfNeeded() /// /// Path to the template xlsx file used for fast test file creation. - /// Copying a template is ~1000x faster than spawning Excel to create a new workbook. + /// Copying a template is ~1000x faster than spawning PowerPoint to create a new presentation. /// private static readonly string TemplateFilePath = Path.Combine( - Path.GetDirectoryName(typeof(ExcelSessionTests).Assembly.Location)!, - "Integration", "Session", "TestFiles", "batch-test-static.xlsx"); + Path.GetDirectoryName(typeof(PptSessionTests).Assembly.Location)!, + "Integration", "Session", "TestFiles", "batch-test-static.pptx"); private static void CreateTempTestFile(string filePath) { - // PERFORMANCE OPTIMIZATION: Copy from template instead of spawning Excel. - // For tests that only need a valid Excel file to exist (not testing creation), + // PERFORMANCE OPTIMIZATION: Copy from template instead of spawning PowerPoint. + // For tests that only need a valid PowerPoint file to exist (not testing creation), // this reduces setup time from ~7-14 seconds to <10ms. File.Copy(TemplateFilePath, filePath); } diff --git a/tests/ExcelMcp.ComInterop.Tests/Integration/Session/SessionManagerOperationTrackingTests.cs b/tests/PptMcp.ComInterop.Tests/Integration/Session/SessionManagerOperationTrackingTests.cs similarity index 89% rename from tests/ExcelMcp.ComInterop.Tests/Integration/Session/SessionManagerOperationTrackingTests.cs rename to tests/PptMcp.ComInterop.Tests/Integration/Session/SessionManagerOperationTrackingTests.cs index bc93e720..5171ea60 100644 --- a/tests/ExcelMcp.ComInterop.Tests/Integration/Session/SessionManagerOperationTrackingTests.cs +++ b/tests/PptMcp.ComInterop.Tests/Integration/Session/SessionManagerOperationTrackingTests.cs @@ -1,8 +1,8 @@ -using Sbroenne.ExcelMcp.ComInterop.Session; +using PptMcp.ComInterop.Session; using Xunit; using Xunit.Abstractions; -namespace Sbroenne.ExcelMcp.ComInterop.Tests.Integration; +namespace PptMcp.ComInterop.Tests.Integration; /// /// Tests for SessionManager operation tracking functionality. @@ -13,7 +13,7 @@ namespace Sbroenne.ExcelMcp.ComInterop.Tests.Integration; [Trait("Speed", "Medium")] [Trait("Layer", "ComInterop")] [Trait("Feature", "SessionManager")] -[Trait("RequiresExcel", "true")] +[Trait("RequiresPowerPoint", "true")] [Collection("Sequential")] public class SessionManagerOperationTrackingTests : IDisposable { @@ -47,20 +47,20 @@ public void Dispose() /// /// Path to the template xlsx file used for fast test file creation. - /// Copying a template is ~1000x faster than spawning Excel to create a new workbook. + /// Copying a template is ~1000x faster than spawning PowerPoint to create a new presentation. /// private static readonly string TemplateFilePath = Path.Combine( Path.GetDirectoryName(typeof(SessionManagerOperationTrackingTests).Assembly.Location)!, - "Integration", "Session", "TestFiles", "batch-test-static.xlsx"); + "Integration", "Session", "TestFiles", "batch-test-static.pptx"); private string CreateTestFile(string testName) { - var fileName = $"{testName}_{Guid.NewGuid():N}.xlsx"; + var fileName = $"{testName}_{Guid.NewGuid():N}.pptx"; #pragma warning disable CA3003 // Path.Combine is safe here - test code with controlled inputs var filePath = Path.Combine(_tempDir, fileName); #pragma warning restore CA3003 - // PERFORMANCE OPTIMIZATION: Copy from template instead of spawning Excel. + // PERFORMANCE OPTIMIZATION: Copy from template instead of spawning PowerPoint. // This reduces test file creation from ~7-14 seconds to <10ms. File.Copy(TemplateFilePath, filePath); @@ -150,39 +150,39 @@ public void BeginEndOperation_NullSessionId_DoesNotThrow() #endregion - #region IsExcelVisible + #region IsPowerPointVisible [Fact] - public void IsExcelVisible_SessionWithShowExcelFalse_ReturnsFalse() + public void IsPowerPointVisible_SessionWithShowPowerPointFalse_ReturnsFalse() { - var testFile = CreateTestFile(nameof(IsExcelVisible_SessionWithShowExcelFalse_ReturnsFalse)); + var testFile = CreateTestFile(nameof(IsPowerPointVisible_SessionWithShowPowerPointFalse_ReturnsFalse)); using var manager = new SessionManager(); var sessionId = manager.CreateSession(testFile, show: false); - Assert.False(manager.IsExcelVisible(sessionId)); + Assert.False(manager.IsPowerPointVisible(sessionId)); manager.CloseSession(sessionId); } [Fact] - public void IsExcelVisible_SessionWithShowExcelTrue_ReturnsTrue() + public void IsPowerPointVisible_SessionWithShowPowerPointTrue_ReturnsTrue() { - var testFile = CreateTestFile(nameof(IsExcelVisible_SessionWithShowExcelTrue_ReturnsTrue)); + var testFile = CreateTestFile(nameof(IsPowerPointVisible_SessionWithShowPowerPointTrue_ReturnsTrue)); using var manager = new SessionManager(); var sessionId = manager.CreateSession(testFile, show: true); - Assert.True(manager.IsExcelVisible(sessionId)); + Assert.True(manager.IsPowerPointVisible(sessionId)); manager.CloseSession(sessionId); } [Fact] - public void IsExcelVisible_NonExistentSession_ReturnsFalse() + public void IsPowerPointVisible_NonExistentSession_ReturnsFalse() { using var manager = new SessionManager(); - Assert.False(manager.IsExcelVisible("nonexistent")); - Assert.False(manager.IsExcelVisible(null!)); + Assert.False(manager.IsPowerPointVisible("nonexistent")); + Assert.False(manager.IsPowerPointVisible(null!)); } #endregion @@ -262,7 +262,7 @@ public void ValidateClose_IncludesVisibilityInfo() var result = manager.ValidateClose(sessionId); - Assert.True(result.IsExcelVisible); + Assert.True(result.IsPowerPointVisible); manager.CloseSession(sessionId); } @@ -348,21 +348,17 @@ public void CloseSession_CleansUpOperationTracking() // After close, these should return defaults Assert.Equal(0, manager.GetActiveOperationCount(sessionId)); - Assert.False(manager.IsExcelVisible(sessionId)); + Assert.False(manager.IsPowerPointVisible(sessionId)); } [Fact] public void Dispose_CleansUpAllTracking() { var testFile1 = CreateTestFile($"{nameof(Dispose_CleansUpAllTracking)}_1"); - var testFile2 = CreateTestFile($"{nameof(Dispose_CleansUpAllTracking)}_2"); using var manager = new SessionManager(); - var session1 = manager.CreateSession(testFile1, show: true); - var session2 = manager.CreateSession(testFile2, show: false); - + var session1 = manager.CreateSession(testFile1, show: false); manager.BeginOperation(session1); - manager.BeginOperation(session2); manager.Dispose(); diff --git a/tests/ExcelMcp.ComInterop.Tests/Integration/Session/SessionManagerTests.cs b/tests/PptMcp.ComInterop.Tests/Integration/Session/SessionManagerTests.cs similarity index 70% rename from tests/ExcelMcp.ComInterop.Tests/Integration/Session/SessionManagerTests.cs rename to tests/PptMcp.ComInterop.Tests/Integration/Session/SessionManagerTests.cs index 8840832d..379e53cd 100644 --- a/tests/ExcelMcp.ComInterop.Tests/Integration/Session/SessionManagerTests.cs +++ b/tests/PptMcp.ComInterop.Tests/Integration/Session/SessionManagerTests.cs @@ -1,9 +1,9 @@ using System.Diagnostics; -using Sbroenne.ExcelMcp.ComInterop.Session; +using PptMcp.ComInterop.Session; using Xunit; using Xunit.Abstractions; -namespace Sbroenne.ExcelMcp.ComInterop.Tests.Integration; +namespace PptMcp.ComInterop.Tests.Integration; /// /// Integration tests for SessionManager - verifies session lifecycle management. @@ -18,14 +18,14 @@ namespace Sbroenne.ExcelMcp.ComInterop.Tests.Integration; /// - ✅ Test disposal cleanup /// - ✅ Test post-disposal protection /// -/// NOTE: SessionManager uses ExcelSession internally, so these tests verify -/// the orchestration layer, not the underlying Excel COM interactions. +/// NOTE: SessionManager uses PptSession internally, so these tests verify +/// the orchestration layer, not the underlying PowerPoint COM interactions. /// [Trait("Category", "Integration")] [Trait("Speed", "Medium")] [Trait("Layer", "ComInterop")] [Trait("Feature", "SessionManager")] -[Trait("RequiresExcel", "true")] +[Trait("RequiresPowerPoint", "true")] [Collection("Sequential")] // Disable parallelization to avoid COM interference public class SessionManagerTests : IDisposable { @@ -39,13 +39,13 @@ public SessionManagerTests(ITestOutputHelper output) _tempDir = Path.Combine(Path.GetTempPath(), $"SessionManagerTests_{Guid.NewGuid():N}"); Directory.CreateDirectory(_tempDir); - // Clean up any existing Excel processes to ensure clean state + // Clean up any existing PowerPoint processes to ensure clean state try { - var existingProcesses = Process.GetProcessesByName("EXCEL"); + var existingProcesses = Process.GetProcessesByName("POWERPNT"); if (existingProcesses.Length > 0) { - _output.WriteLine($"Cleaning up {existingProcesses.Length} existing Excel processes..."); + _output.WriteLine($"Cleaning up {existingProcesses.Length} existing PowerPoint processes..."); foreach (var p in existingProcesses) { p.Kill(entireProcessTree: true); @@ -56,7 +56,7 @@ public SessionManagerTests(ITestOutputHelper output) } catch (Exception ex) { - _output.WriteLine($"Warning: Failed to clean Excel processes: {ex.Message}"); + _output.WriteLine($"Warning: Failed to clean PowerPoint processes: {ex.Message}"); } } @@ -82,20 +82,20 @@ public void Dispose() /// /// Path to the template xlsx file used for fast test file creation. - /// Copying a template is ~1000x faster than spawning Excel to create a new workbook. + /// Copying a template is ~1000x faster than spawning PowerPoint to create a new presentation. /// private static readonly string TemplateFilePath = Path.Combine( Path.GetDirectoryName(typeof(SessionManagerTests).Assembly.Location)!, - "Integration", "Session", "TestFiles", "batch-test-static.xlsx"); + "Integration", "Session", "TestFiles", "batch-test-static.pptx"); private string CreateTestFile(string testName) { - var fileName = $"{testName}_{Guid.NewGuid():N}.xlsx"; + var fileName = $"{testName}_{Guid.NewGuid():N}.pptx"; var filePath = Path.Combine(_tempDir, fileName); - // PERFORMANCE OPTIMIZATION: Copy from template instead of spawning Excel. + // PERFORMANCE OPTIMIZATION: Copy from template instead of spawning PowerPoint. // This reduces test file creation from ~7-14 seconds to <10ms. - // Original approach using ExcelSession.CreateNew() spawned a full Excel process + // Original approach using PptSession.CreateNew() spawned a full PowerPoint process // for each test file, causing 30+ second test execution times. File.Copy(TemplateFilePath, filePath); @@ -124,12 +124,12 @@ public void CreateSession_ValidFile_ReturnsSessionId() public void CreateSession_NonExistentFile_ThrowsFileNotFoundException() { using var manager = new SessionManager(); - var nonExistentFile = Path.Combine(_tempDir, "nonexistent.xlsx"); + var nonExistentFile = Path.Combine(_tempDir, "nonexistent.pptx"); var ex = Assert.Throws( () => manager.CreateSession(nonExistentFile)); - Assert.Contains("Excel file not found", ex.Message); + Assert.Contains("PowerPoint file not found", ex.Message); Assert.Equal(0, manager.ActiveSessionCount); } @@ -180,13 +180,15 @@ public void CloseSession_WithSaveTrue_SavesAndCloses() using var manager = new SessionManager(); var sessionId = manager.CreateSession(testFile); - // Modify data to verify save + // Add a slide as marker of changes var batch = manager.GetSession(sessionId); Assert.NotNull(batch); batch.Execute((ctx, ct) => { - dynamic sheet = ctx.Book.Worksheets[1]; - sheet.Cells[1, 1].Value2 = "Test Value"; + dynamic slides = ctx.Presentation.Slides; + dynamic layouts = ((dynamic)ctx.Presentation).SlideMaster.CustomLayouts; + dynamic layout = layouts[1]; + slides.AddSlide(slides.Count + 1, layout); return 0; }); @@ -195,14 +197,13 @@ public void CloseSession_WithSaveTrue_SavesAndCloses() Assert.True(closed); Assert.Equal(0, manager.ActiveSessionCount); - // Verify changes persisted - using var verifyBatch = ExcelSession.BeginBatch(testFile); - var value = verifyBatch.Execute((ctx, ct) => + // Verify changes persisted (extra slide still there) + using var verifyBatch = PptSession.BeginBatch(testFile); + var slideCount = verifyBatch.Execute((ctx, ct) => { - dynamic sheet = ctx.Book.Worksheets[1]; - return (string)sheet.Cells[1, 1].Value2; + return (int)ctx.Presentation.Slides.Count; }); - Assert.Equal("Test Value", value); + Assert.True(slideCount > 1, $"Expected more than 1 slide after save, got {slideCount}"); } [Fact] @@ -212,13 +213,18 @@ public void CloseSession_WithSaveFalse_DiscardsChanges() using var manager = new SessionManager(); var sessionId = manager.CreateSession(testFile); - // Modify data but don't save + // Get initial slide count var batch = manager.GetSession(sessionId); Assert.NotNull(batch); + var initialCount = batch.Execute((ctx, ct) => (int)ctx.Presentation.Slides.Count); + + // Add a slide but don't save batch.Execute((ctx, ct) => { - dynamic sheet = ctx.Book.Worksheets[1]; - sheet.Cells[1, 1].Value2 = "Discarded Value"; + dynamic slides = ctx.Presentation.Slides; + dynamic layouts = ((dynamic)ctx.Presentation).SlideMaster.CustomLayouts; + dynamic layout = layouts[1]; + slides.AddSlide(slides.Count + 1, layout); return 0; }); @@ -228,13 +234,12 @@ public void CloseSession_WithSaveFalse_DiscardsChanges() Assert.Equal(0, manager.ActiveSessionCount); // Verify changes were NOT persisted - using var verifyBatch = ExcelSession.BeginBatch(testFile); - var value = verifyBatch.Execute((ctx, ct) => + using var verifyBatch = PptSession.BeginBatch(testFile); + var slideCount = verifyBatch.Execute((ctx, ct) => { - dynamic sheet = ctx.Book.Worksheets[1]; - return sheet.Cells[1, 1].Value2; + return (int)ctx.Presentation.Slides.Count; }); - Assert.Null(value); // Cell should be empty + Assert.Equal(initialCount, slideCount); // Should be same as before } #endregion @@ -282,79 +287,76 @@ public void CloseSession_AlreadyClosedSession_ReturnsFalse() #endregion - #region Multi-Session Scenarios + #region Single-Session Constraint [Fact] - public void CreateMultipleSessions_DifferentFiles_TracksAllSessions() + public void CreateSession_WhileSessionActive_ThrowsInvalidOperationException() { - var testFile1 = CreateTestFile($"{nameof(CreateMultipleSessions_DifferentFiles_TracksAllSessions)}_1"); - var testFile2 = CreateTestFile($"{nameof(CreateMultipleSessions_DifferentFiles_TracksAllSessions)}_2"); + var testFile1 = CreateTestFile($"{nameof(CreateSession_WhileSessionActive_ThrowsInvalidOperationException)}_1"); + var testFile2 = CreateTestFile($"{nameof(CreateSession_WhileSessionActive_ThrowsInvalidOperationException)}_2"); using var manager = new SessionManager(); var sessionId1 = manager.CreateSession(testFile1); - var sessionId2 = manager.CreateSession(testFile2); + Assert.Equal(1, manager.ActiveSessionCount); - Assert.Equal(2, manager.ActiveSessionCount); - Assert.Contains(sessionId1, manager.ActiveSessionIds); - Assert.Contains(sessionId2, manager.ActiveSessionIds); + // Second session should fail — PowerPoint COM is single-instance + var ex = Assert.Throws( + () => manager.CreateSession(testFile2)); + Assert.Contains("single-instance", ex.Message); + Assert.Equal(1, manager.ActiveSessionCount); manager.CloseSession(sessionId1); - manager.CloseSession(sessionId2); } [Fact] public void ActiveSessionIds_ReflectsCurrentState() { - var testFile1 = CreateTestFile($"{nameof(ActiveSessionIds_ReflectsCurrentState)}_1"); - var testFile2 = CreateTestFile($"{nameof(ActiveSessionIds_ReflectsCurrentState)}_2"); + var testFile = CreateTestFile(nameof(ActiveSessionIds_ReflectsCurrentState)); using var manager = new SessionManager(); // Initially empty Assert.Empty(manager.ActiveSessionIds); - // After creating sessions - var sessionId1 = manager.CreateSession(testFile1); - var sessionId2 = manager.CreateSession(testFile2); + // After creating session + var sessionId = manager.CreateSession(testFile); var activeIds = manager.ActiveSessionIds.ToList(); - Assert.Equal(2, activeIds.Count); - Assert.Contains(sessionId1, activeIds); - Assert.Contains(sessionId2, activeIds); + Assert.Single(activeIds); + Assert.Contains(sessionId, activeIds); - // After closing one session - manager.CloseSession(sessionId1); + // After closing session + manager.CloseSession(sessionId); activeIds = manager.ActiveSessionIds.ToList(); - Assert.Single(activeIds); - Assert.Contains(sessionId2, activeIds); - Assert.DoesNotContain(sessionId1, activeIds); - - manager.CloseSession(sessionId2); + Assert.Empty(activeIds); } [Fact] - public void CloseOneSession_DoesNotAffectOtherSessions() + public void CloseAndReopen_DifferentFile_WorksCorrectly() { - var testFile1 = CreateTestFile($"{nameof(CloseOneSession_DoesNotAffectOtherSessions)}_1"); - var testFile2 = CreateTestFile($"{nameof(CloseOneSession_DoesNotAffectOtherSessions)}_2"); + var testFile1 = CreateTestFile($"{nameof(CloseAndReopen_DifferentFile_WorksCorrectly)}_1"); + var testFile2 = CreateTestFile($"{nameof(CloseAndReopen_DifferentFile_WorksCorrectly)}_2"); using var manager = new SessionManager(); + // Open first file var sessionId1 = manager.CreateSession(testFile1); - var sessionId2 = manager.CreateSession(testFile2); + Assert.Equal(1, manager.ActiveSessionCount); + // Close first, then open second manager.CloseSession(sessionId1); + Assert.Equal(0, manager.ActiveSessionCount); + var sessionId2 = manager.CreateSession(testFile2); Assert.Equal(1, manager.ActiveSessionCount); - Assert.Null(manager.GetSession(sessionId1)); Assert.NotNull(manager.GetSession(sessionId2)); manager.CloseSession(sessionId2); } [Fact] - public void CreateSession_SameFileAlreadyOpen_ThrowsInvalidOperationException() + public void CreateSession_SameFileAlreadyOpen_ThrowsSingleSessionException() { - var testFile = CreateTestFile(nameof(CreateSession_SameFileAlreadyOpen_ThrowsInvalidOperationException)); + var testFile = CreateTestFile(nameof(CreateSession_SameFileAlreadyOpen_ThrowsSingleSessionException)); using var manager = new SessionManager(); // First session succeeds @@ -362,13 +364,12 @@ public void CreateSession_SameFileAlreadyOpen_ThrowsInvalidOperationException() Assert.NotNull(sessionId1); Assert.Equal(1, manager.ActiveSessionCount); - // Second session with same file should fail fast + // Second session with same file should fail — single-session constraint fires first var ex = Assert.Throws( () => manager.CreateSession(testFile)); - Assert.Contains("already open in another session", ex.Message); - Assert.Contains("Excel cannot open the same file multiple times", ex.Message); - Assert.Equal(1, manager.ActiveSessionCount); // Still only one session + Assert.Contains("single-instance", ex.Message); + Assert.Equal(1, manager.ActiveSessionCount); manager.CloseSession(sessionId1); } @@ -401,9 +402,9 @@ public void CreateSession_AfterClosingPrevious_AllowsReopeningFile() #region Disposal and Post-Disposal [Fact] - public void Dispose_OneSession_ClosesAllSessions() + public void Dispose_OneSession_ClosesSession() { - var testFile1 = CreateTestFile($"{nameof(Dispose_OneSession_ClosesAllSessions)}_1"); + var testFile1 = CreateTestFile($"{nameof(Dispose_OneSession_ClosesSession)}_1"); var manager = new SessionManager(); var sessionId1 = manager.CreateSession(testFile1); @@ -415,25 +416,6 @@ public void Dispose_OneSession_ClosesAllSessions() Assert.Empty(manager.ActiveSessionIds); } - [Fact] - public void Dispose_TwoSessions_ClosesAllSessions() - { - var testFile1 = CreateTestFile($"{nameof(Dispose_TwoSessions_ClosesAllSessions)}_1"); - var testFile2 = CreateTestFile($"{nameof(Dispose_TwoSessions_ClosesAllSessions)}_2"); - var manager = new SessionManager(); - - manager.CreateSession(testFile1); - manager.CreateSession(testFile2); - - Assert.Equal(2, manager.ActiveSessionCount); - - // DisposeAsync handles sessions sequentially to avoid COM threading issues - manager.Dispose(); - - Assert.Equal(0, manager.ActiveSessionCount); - Assert.Empty(manager.ActiveSessionIds); - } - [Fact] public void Dispose_EmptyManager_CompletesImmediately() { @@ -502,9 +484,9 @@ public void CreateSession_VeryLongFilePath_HandlesGracefully() try { Directory.CreateDirectory(longDir); - var longFilePath = Path.Combine(longDir, "test.xlsx"); + var longFilePath = Path.Combine(longDir, "test.pptx"); - // Copy template file to the long path (faster than spawning Excel) + // Copy template file to the long path (faster than spawning PowerPoint) File.Copy(TemplateFilePath, longFilePath); _testFiles.Add(longFilePath); @@ -523,20 +505,20 @@ public void CreateSession_VeryLongFilePath_HandlesGracefully() } catch (AggregateException ex) when (ex.InnerException is PathTooLongException) { - // Excel COM may reject very long paths - expected behavior (converted from COMException) - _output.WriteLine($"Excel rejected long path - test skipped: {ex.InnerException.Message}"); + // PowerPoint COM may reject very long paths - expected behavior (converted from COMException) + _output.WriteLine($"PowerPoint rejected long path - test skipped: {ex.InnerException.Message}"); } catch (AggregateException ex) when (ex.InnerException is AggregateException inner && inner.InnerException is PathTooLongException) { // Nested AggregateException from async task wrapping (STA thread -> Task.Wait -> Task.Wait) - _output.WriteLine($"Excel rejected long path (nested) - test skipped: {((AggregateException)ex.InnerException).InnerException!.Message}"); + _output.WriteLine($"PowerPoint rejected long path (nested) - test skipped: {((AggregateException)ex.InnerException).InnerException!.Message}"); } - catch (InvalidOperationException ex) when (ex.Message.Contains("already open") || ex.Message.Contains("Cannot open")) + catch (InvalidOperationException ex) when (ex.Message.Contains("already open") || ex.Message.Contains("Cannot open") || ex.Message.Contains("255 characters") || ex.Message.Contains("Filename cannot exceed")) { - // Excel COM returns generic "file already open" error (Error 1004) for paths it can't handle. - // This is a misleading error message - the real issue is the path is too long for Excel COM. + // PowerPoint COM returns generic errors for paths it can't handle. + // This is a misleading error message - the real issue is the path is too long for PowerPoint COM. // We accept this as equivalent to PathTooLongException for test purposes. - _output.WriteLine($"Excel COM rejected long path with generic error - test skipped: {ex.Message}"); + _output.WriteLine($"PowerPoint COM rejected long path with generic error - test skipped: {ex.Message}"); } } @@ -553,24 +535,25 @@ public void CloseSession_DefaultSaveTrue_PersistsChanges() batch.Execute((ctx, ct) => { - dynamic sheet = ctx.Book.Worksheets[1]; - sheet.Cells[1, 1].Value2 = "Test Value"; + dynamic slides = ctx.Presentation.Slides; + dynamic layouts = ((dynamic)ctx.Presentation).SlideMaster.CustomLayouts; + dynamic layout = layouts[1]; + slides.AddSlide(slides.Count + 1, layout); return 0; }); - // Close with default save=false, but pass save:true explicitly + // Close with save:true explicitly var closed = manager.CloseSession(sessionId, save: true); Assert.True(closed); - // Verify changes persisted - using var verifyBatch = ExcelSession.BeginBatch(testFile); - var value = verifyBatch.Execute((ctx, ct) => + // Verify changes persisted (extra slide should be there) + using var verifyBatch = PptSession.BeginBatch(testFile); + var slideCount = verifyBatch.Execute((ctx, ct) => { - dynamic sheet = ctx.Book.Worksheets[1]; - return (string)sheet.Cells[1, 1].Value2; + return (int)ctx.Presentation.Slides.Count; }); - Assert.Equal("Test Value", value); + Assert.True(slideCount > 1, $"Expected more than 1 slide after save, got {slideCount}"); } #endregion diff --git a/tests/ExcelMcp.ComInterop.Tests/Integration/Session/SessionManagerTimeoutTests.cs b/tests/PptMcp.ComInterop.Tests/Integration/Session/SessionManagerTimeoutTests.cs similarity index 83% rename from tests/ExcelMcp.ComInterop.Tests/Integration/Session/SessionManagerTimeoutTests.cs rename to tests/PptMcp.ComInterop.Tests/Integration/Session/SessionManagerTimeoutTests.cs index 6444b854..004d0010 100644 --- a/tests/ExcelMcp.ComInterop.Tests/Integration/Session/SessionManagerTimeoutTests.cs +++ b/tests/PptMcp.ComInterop.Tests/Integration/Session/SessionManagerTimeoutTests.cs @@ -1,14 +1,14 @@ using System.Diagnostics; -using Sbroenne.ExcelMcp.ComInterop.Session; +using PptMcp.ComInterop.Session; using Xunit; using Xunit.Abstractions; -namespace Sbroenne.ExcelMcp.ComInterop.Tests.Integration.Session; +namespace PptMcp.ComInterop.Tests.Integration.Session; /// /// Tests for SessionManager behavior when operations timeout. /// -/// These tests validate the integration between ExcelBatch timeout detection +/// These tests validate the integration between PptBatch timeout detection /// and SessionManager session cleanup — the complete recovery path that was /// missing before Bug 8 (Feb 2026). /// @@ -16,7 +16,7 @@ namespace Sbroenne.ExcelMcp.ComInterop.Tests.Integration.Session; /// - ✅ Test that SessionManager.CloseSession(force:true) works after timeout /// - ✅ Test that session is removed after timeout + force close /// - ✅ Test that subsequent GetSession returns null after timeout cleanup -/// - ✅ Test that Excel process is cleaned up end-to-end through SessionManager +/// - ✅ Test that PowerPoint process is cleaned up end-to-end through SessionManager /// [Trait("Category", "Integration")] [Trait("Speed", "Slow")] @@ -32,7 +32,7 @@ public class SessionManagerTimeoutTests : IDisposable private static readonly string TemplateFilePath = Path.Combine( Path.GetDirectoryName(typeof(SessionManagerTimeoutTests).Assembly.Location)!, - "Integration", "Session", "TestFiles", "batch-test-static.xlsx"); + "Integration", "Session", "TestFiles", "batch-test-static.pptx"); public SessionManagerTimeoutTests(ITestOutputHelper output) { @@ -62,7 +62,7 @@ public void Dispose() private string CreateTestFile(string testName) { - var filePath = Path.Combine(_tempDir, $"{testName}_{Guid.NewGuid():N}.xlsx"); + var filePath = Path.Combine(_tempDir, $"{testName}_{Guid.NewGuid():N}.pptx"); File.Copy(TemplateFilePath, filePath); _testFiles.Add(filePath); return filePath; @@ -88,7 +88,7 @@ public void CloseSession_AfterTimeout_RemovesSessionAndCleansUp() Assert.NotNull(batch); // Warm up - batch.Execute((ctx, ct) => { _ = ctx.Book.Worksheets[1]; return 0; }); + batch.Execute((ctx, ct) => { _ = ctx.Presentation.Slides.Count; return 0; }); // Trigger timeout var ex = Assert.Throws(() => @@ -113,24 +113,24 @@ public void CloseSession_AfterTimeout_RemovesSessionAndCleansUp() } /// - /// REGRESSION TEST: After timeout + force close, the Excel process must be terminated. + /// REGRESSION TEST: After timeout + force close, the PowerPoint process must be terminated. /// This is the end-to-end test for the complete Bug 8 recovery chain: /// timeout → force close → pre-emptive kill → process cleanup. /// [Fact] - public void CloseSession_AfterTimeout_ExcelProcessIsTerminated() + public void CloseSession_AfterTimeout_PowerPointProcessIsTerminated() { // Arrange - var testFile = CreateTestFile(nameof(CloseSession_AfterTimeout_ExcelProcessIsTerminated)); + var testFile = CreateTestFile(nameof(CloseSession_AfterTimeout_PowerPointProcessIsTerminated)); using var manager = new SessionManager(); var sessionId = manager.CreateSession(testFile, operationTimeout: TimeSpan.FromSeconds(3)); var batch = manager.GetSession(sessionId)!; - int? excelPid = batch.ExcelProcessId; - _output.WriteLine($"Session {sessionId}, Excel PID: {excelPid}"); + int? pptPid = batch.PowerPointProcessId; + _output.WriteLine($"Session {sessionId}, PowerPoint PID: {pptPid}"); // Warm up - batch.Execute((ctx, ct) => { _ = ctx.Book.Worksheets[1]; return 0; }); + batch.Execute((ctx, ct) => { _ = ctx.Presentation.Slides.Count; return 0; }); // Trigger timeout Assert.Throws(() => @@ -151,13 +151,13 @@ public void CloseSession_AfterTimeout_ExcelProcessIsTerminated() // Wait for process cleanup Thread.Sleep(2000); - // Assert — Excel process should be dead - if (excelPid.HasValue) + // Assert — PowerPoint process should be dead + if (pptPid.HasValue) { bool processAlive; try { - using var process = Process.GetProcessById(excelPid.Value); + using var process = Process.GetProcessById(pptPid.Value); processAlive = !process.HasExited; } catch (ArgumentException) @@ -166,10 +166,10 @@ public void CloseSession_AfterTimeout_ExcelProcessIsTerminated() } Assert.False(processAlive, - $"REGRESSION: Excel process {excelPid.Value} still alive after timeout + force close. " + + $"REGRESSION: PowerPoint process {pptPid.Value} still alive after timeout + force close. " + "The pre-emptive kill in Dispose() may not be working."); - _output.WriteLine($"✓ Excel process {excelPid.Value} terminated"); + _output.WriteLine($"✓ PowerPoint process {pptPid.Value} terminated"); } } @@ -189,7 +189,7 @@ public void Execute_WithinTimeout_SucceedsNormally() // Act — quick operation should succeed var result = batch.Execute((ctx, ct) => { - dynamic sheet = ctx.Book.Worksheets[1]; + dynamic sheet = ctx.Presentation.Slides[1]; return sheet.Name?.ToString() ?? "unknown"; }); diff --git a/tests/PptMcp.ComInterop.Tests/Integration/Session/TestFiles/batch-test-static.pptx b/tests/PptMcp.ComInterop.Tests/Integration/Session/TestFiles/batch-test-static.pptx new file mode 100644 index 00000000..b6bb002e Binary files /dev/null and b/tests/PptMcp.ComInterop.Tests/Integration/Session/TestFiles/batch-test-static.pptx differ diff --git a/tests/ExcelMcp.ComInterop.Tests/ExcelMcp.ComInterop.Tests.csproj b/tests/PptMcp.ComInterop.Tests/PptMcp.ComInterop.Tests.csproj similarity index 85% rename from tests/ExcelMcp.ComInterop.Tests/ExcelMcp.ComInterop.Tests.csproj rename to tests/PptMcp.ComInterop.Tests/PptMcp.ComInterop.Tests.csproj index d84121db..577e7dff 100644 --- a/tests/ExcelMcp.ComInterop.Tests/ExcelMcp.ComInterop.Tests.csproj +++ b/tests/PptMcp.ComInterop.Tests/PptMcp.ComInterop.Tests.csproj @@ -1,7 +1,7 @@ - net10.0-windows + net9.0-windows true $(NoWarn);CS0618 latest @@ -11,8 +11,8 @@ true ComInteropTests.runsettings - Sbroenne.ExcelMcp.ComInterop.Tests - Sbroenne.ExcelMcp.ComInterop.Tests + PptMcp.ComInterop.Tests + PptMcp.ComInterop.Tests true @@ -35,7 +35,7 @@ - + diff --git a/tests/ExcelMcp.ComInterop.Tests/Unit/ComUtilitiesExtendedTests.cs b/tests/PptMcp.ComInterop.Tests/Unit/ComUtilitiesExtendedTests.cs similarity index 96% rename from tests/ExcelMcp.ComInterop.Tests/Unit/ComUtilitiesExtendedTests.cs rename to tests/PptMcp.ComInterop.Tests/Unit/ComUtilitiesExtendedTests.cs index fb7c5e01..e8cf81b6 100644 --- a/tests/ExcelMcp.ComInterop.Tests/Unit/ComUtilitiesExtendedTests.cs +++ b/tests/PptMcp.ComInterop.Tests/Unit/ComUtilitiesExtendedTests.cs @@ -1,6 +1,6 @@ using Xunit; -namespace Sbroenne.ExcelMcp.ComInterop.Tests.Unit; +namespace PptMcp.ComInterop.Tests.Unit; /// /// Extended tests for ComUtilities - tests error handling and edge cases. diff --git a/tests/ExcelMcp.ComInterop.Tests/Unit/ComUtilitiesTests.cs b/tests/PptMcp.ComInterop.Tests/Unit/ComUtilitiesTests.cs similarity index 95% rename from tests/ExcelMcp.ComInterop.Tests/Unit/ComUtilitiesTests.cs rename to tests/PptMcp.ComInterop.Tests/Unit/ComUtilitiesTests.cs index d9efef1c..da4d6b68 100644 --- a/tests/ExcelMcp.ComInterop.Tests/Unit/ComUtilitiesTests.cs +++ b/tests/PptMcp.ComInterop.Tests/Unit/ComUtilitiesTests.cs @@ -1,6 +1,6 @@ using Xunit; -namespace Sbroenne.ExcelMcp.ComInterop.Tests.Unit; +namespace PptMcp.ComInterop.Tests.Unit; /// /// Unit tests for ComUtilities helper methods. diff --git a/tests/ExcelMcp.ComInterop.Tests/Unit/OleMessageFilterTests.cs b/tests/PptMcp.ComInterop.Tests/Unit/OleMessageFilterTests.cs similarity index 89% rename from tests/ExcelMcp.ComInterop.Tests/Unit/OleMessageFilterTests.cs rename to tests/PptMcp.ComInterop.Tests/Unit/OleMessageFilterTests.cs index f752328b..3aa5611e 100644 --- a/tests/ExcelMcp.ComInterop.Tests/Unit/OleMessageFilterTests.cs +++ b/tests/PptMcp.ComInterop.Tests/Unit/OleMessageFilterTests.cs @@ -1,13 +1,13 @@ using Xunit; -namespace Sbroenne.ExcelMcp.ComInterop.Tests.Unit; +namespace PptMcp.ComInterop.Tests.Unit; /// /// Unit tests for OleMessageFilter registration and revocation. /// Tests verify that the message filter can be registered/revoked without errors. /// /// NOTE: These tests verify the registration mechanism but don't test actual -/// COM retry behavior (that requires Excel and would be OnDemand tests). +/// COM retry behavior (that requires PowerPoint and would be OnDemand tests). /// [Trait("Category", "Unit")] [Trait("Speed", "Fast")] @@ -73,12 +73,12 @@ public void Revoke_WithoutRegister_DoesNotThrow() /// MessagePending MUST return PENDINGMSG_WAITDEFPROCESS (1), NOT PENDINGMSG_WAITNOPROCESS (2). /// /// Returning 2 (WAITNOPROCESS) blocks ALL inbound COM message processing while an outgoing - /// call is in progress. When Excel fires a re-entrant callback (e.g., Calculate, SheetChange) + /// call is in progress. When PowerPoint fires a re-entrant callback (e.g., Calculate, SheetChange) /// during FormatConditions.Add(), the callback is queued but WAITNOPROCESS prevents it from - /// being dispatched. Excel waits for the callback → STA thread waits for Excel → deadlock. + /// being dispatched. PowerPoint waits for the callback → STA thread waits for PowerPoint → deadlock. /// /// Returning 1 (WAITDEFPROCESS) allows COM to process the pending inbound call, letting - /// Excel's callback complete so FormatConditions.Add() can return normally. + /// PowerPoint's callback complete so FormatConditions.Add() can return normally. /// [Fact] public void MessagePending_ReturnValue_MustBe_WaitDefProcess() @@ -112,7 +112,7 @@ public void MessagePending_ReturnValue_MustBe_WaitDefProcess() // The filter class is internal, but we can get to it via the assembly. var filterType = typeof(OleMessageFilter); var iOleMsgFilterType = filterType.Assembly.GetType( - "Sbroenne.ExcelMcp.ComInterop.IOleMessageFilter"); + "PptMcp.ComInterop.IOleMessageFilter"); Assert.NotNull(iOleMsgFilterType); // Create a filter instance and call MessagePending @@ -137,7 +137,7 @@ public void MessagePending_ReturnValue_MustBe_WaitDefProcess() if (threadException != null) throw new InvalidOperationException($"Thread exception: {threadException.Message}", threadException); // REGRESSION: If this returns 2 (WAITNOPROCESS), conditional formatting on cells - // with formulas will deadlock because Excel's Calculate/SheetChange callbacks + // with formulas will deadlock because PowerPoint's Calculate/SheetChange callbacks // can't be delivered while the STA thread waits for FormatConditions.Add(). Assert.NotEqual(PENDINGMSG_WAITNOPROCESS, returnValue); Assert.Equal(PENDINGMSG_WAITDEFPROCESS, returnValue); diff --git a/tests/PptMcp.ComInterop.Tests/Unit/PptContextTests.cs b/tests/PptMcp.ComInterop.Tests/Unit/PptContextTests.cs new file mode 100644 index 00000000..c26d2281 --- /dev/null +++ b/tests/PptMcp.ComInterop.Tests/Unit/PptContextTests.cs @@ -0,0 +1,108 @@ +using PptMcp.ComInterop.Session; +using Xunit; + +namespace PptMcp.ComInterop.Tests.Unit; + +/// +/// Unit tests for PptContext - validates constructor and property behavior. +/// This class is a simple data holder, so tests focus on path validation and immutability. +/// Note: PowerPoint.Application and PowerPoint.Presentation COM objects cannot be mocked in unit tests, +/// so these tests use null! for those parameters and verify only what is testable. +/// +[Trait("Category", "Unit")] +[Trait("Speed", "Fast")] +[Trait("Layer", "ComInterop")] +public class PptContextTests +{ + [Fact] + public void Constructor_WithValidArguments_SetsPresentationPathCorrectly() + { + // Arrange + string presentationPath = @"C:\test\presentation.pptx"; + + // Act & Assert - Constructor throws ArgumentNullException for null COM objects, + // which is expected behavior. PresentationPath validation is tested separately. + var ex = Assert.Throws(() => + new PptContext(presentationPath, null!, null!)); + + // When null is passed, the constructor throws on the first null param (powerpoint) + Assert.NotNull(ex); + } + + [Fact] + public void Constructor_WithNullPresentationPath_ThrowsArgumentNullException() + { + // Arrange + string? presentationPath = null; + + // Act & Assert + var ex = Assert.Throws(() => + new PptContext(presentationPath!, null!, null!)); + + Assert.Equal("presentationPath", ex.ParamName); + } + + [Fact] + public void Constructor_WithNullPowerPoint_ThrowsArgumentNullException() + { + // Arrange + string presentationPath = @"C:\test\presentation.pptx"; + + // Act & Assert + var ex = Assert.Throws(() => + new PptContext(presentationPath, null!, null!)); + + Assert.Equal("app", ex.ParamName); + } + + [Fact] + public void Constructor_WithNullPresentationPath_ThrowsBeforeNullPowerPoint() + { + // Arrange + string? presentationPath = null; + + // Act & Assert - PresentationPath is validated first + var ex = Assert.Throws(() => + new PptContext(presentationPath!, null!, null!)); + + Assert.Equal("presentationPath", ex.ParamName); + } + + [Fact] + public void Constructor_PresentationPathValidation_RejectsNull() + { + // Arrange & Act & Assert + var ex = Assert.Throws(() => + new PptContext(null!, null!, null!)); + + Assert.Equal("presentationPath", ex.ParamName); + } + + [Theory] + [InlineData(@"C:\test\presentation.pptx")] + [InlineData(@"\\server\share\presentation.pptm")] + [InlineData(@"D:\Documents\My Presentation.pptx")] + [InlineData(@"presentation.pptx")] // Relative path + public void Constructor_WithNullPowerPointAnyPath_ThrowsArgumentNullException(string presentationPath) + { + // Act & Assert - Path is validated, then PowerPoint COM object is validated + var ex = Assert.Throws(() => + new PptContext(presentationPath, null!, null!)); + + // app is the first COM parameter validated after presentationPath + Assert.Equal("app", ex.ParamName); + } + + [Fact] + public void Constructor_NullPresentationPath_ThrowsWithCorrectParamName() + { + // Arrange - Simulates null path being passed + Assert.Throws(() => + new PptContext(null!, null!, null!)); + } +} + + + + + diff --git a/tests/ExcelMcp.ComInterop.Tests/xunit.runner.json b/tests/PptMcp.ComInterop.Tests/xunit.runner.json similarity index 100% rename from tests/ExcelMcp.ComInterop.Tests/xunit.runner.json rename to tests/PptMcp.ComInterop.Tests/xunit.runner.json diff --git a/tests/ExcelMcp.Core.Tests/Helpers/ResultAwaiterExtensions.cs b/tests/PptMcp.Core.Tests/Helpers/ResultAwaiterExtensions.cs similarity index 90% rename from tests/ExcelMcp.Core.Tests/Helpers/ResultAwaiterExtensions.cs rename to tests/PptMcp.Core.Tests/Helpers/ResultAwaiterExtensions.cs index f16fb37b..db7a1dd4 100644 --- a/tests/ExcelMcp.Core.Tests/Helpers/ResultAwaiterExtensions.cs +++ b/tests/PptMcp.Core.Tests/Helpers/ResultAwaiterExtensions.cs @@ -1,7 +1,7 @@ using System.Runtime.CompilerServices; -using Sbroenne.ExcelMcp.Core.Models; +using PptMcp.Core.Models; -namespace Sbroenne.ExcelMcp.Core.Tests.Helpers; +namespace PptMcp.Core.Tests.Helpers; /// /// Enables awaiting synchronous Core command results in tests without changing production APIs. diff --git a/tests/ExcelMcp.Core.Tests/Helpers/TempDirectoryFixture.cs b/tests/PptMcp.Core.Tests/Helpers/TempDirectoryFixture.cs similarity index 86% rename from tests/ExcelMcp.Core.Tests/Helpers/TempDirectoryFixture.cs rename to tests/PptMcp.Core.Tests/Helpers/TempDirectoryFixture.cs index 0f59d3ff..b4a51579 100644 --- a/tests/ExcelMcp.Core.Tests/Helpers/TempDirectoryFixture.cs +++ b/tests/PptMcp.Core.Tests/Helpers/TempDirectoryFixture.cs @@ -1,7 +1,7 @@ using System.Runtime.CompilerServices; -using Sbroenne.ExcelMcp.ComInterop.Session; +using PptMcp.ComInterop.Session; -namespace Sbroenne.ExcelMcp.Core.Tests.Helpers; +namespace PptMcp.Core.Tests.Helpers; /// /// xUnit test fixture that provides temp directory management for integration tests. @@ -31,12 +31,12 @@ public class TempDirectoryFixture : IDisposable public string TempDir { get; } /// - /// Creates a unique test Excel file for the calling test method. + /// Creates a unique test PowerPoint file for the calling test method. /// /// Auto-populated with the calling method name. - /// File extension (default: .xlsx). + /// File extension (default: .pptx). /// Full path to the created file. - public string CreateTestFile([CallerMemberName] string testName = "", string extension = ".xlsx") + public string CreateTestFile([CallerMemberName] string testName = "", string extension = ".pptx") { var fileName = $"{testName}_{Guid.NewGuid():N}{extension}"; var filePath = Path.Combine(TempDir, fileName); @@ -53,7 +53,7 @@ public string CreateTestFile([CallerMemberName] string testName = "", string ext /// public TempDirectoryFixture() { - TempDir = Path.Join(Path.GetTempPath(), $"ExcelMcp_Tests_{Guid.NewGuid():N}"); + TempDir = Path.Join(Path.GetTempPath(), $"PptMcp_Tests_{Guid.NewGuid():N}"); Directory.CreateDirectory(TempDir); } diff --git a/tests/ExcelMcp.Core.Tests/ExcelMcp.Core.Tests.csproj b/tests/PptMcp.Core.Tests/PptMcp.Core.Tests.csproj similarity index 83% rename from tests/ExcelMcp.Core.Tests/ExcelMcp.Core.Tests.csproj rename to tests/PptMcp.Core.Tests/PptMcp.Core.Tests.csproj index 3f31078b..7fd91662 100644 --- a/tests/ExcelMcp.Core.Tests/ExcelMcp.Core.Tests.csproj +++ b/tests/PptMcp.Core.Tests/PptMcp.Core.Tests.csproj @@ -1,7 +1,7 @@ - net10.0-windows + net9.0-windows true $(NoWarn);CS1591;CA1707;IDE0005 latest @@ -11,8 +11,8 @@ true - Sbroenne.ExcelMcp.Core.Tests - Sbroenne.ExcelMcp.Core.Tests + PptMcp.Core.Tests + PptMcp.Core.Tests @@ -31,7 +31,7 @@ - + diff --git a/tests/ExcelMcp.Core.Tests/TestData/sales-data.csv b/tests/PptMcp.Core.Tests/TestData/sales-data.csv similarity index 100% rename from tests/ExcelMcp.Core.Tests/TestData/sales-data.csv rename to tests/PptMcp.Core.Tests/TestData/sales-data.csv diff --git a/tests/ExcelMcp.Core.Tests/Unit/ParameterTransformsFileTests.cs b/tests/PptMcp.Core.Tests/Unit/ParameterTransformsFileTests.cs similarity index 73% rename from tests/ExcelMcp.Core.Tests/Unit/ParameterTransformsFileTests.cs rename to tests/PptMcp.Core.Tests/Unit/ParameterTransformsFileTests.cs index 77d9c0e4..5c8caec7 100644 --- a/tests/ExcelMcp.Core.Tests/Unit/ParameterTransformsFileTests.cs +++ b/tests/PptMcp.Core.Tests/Unit/ParameterTransformsFileTests.cs @@ -1,7 +1,7 @@ -using Sbroenne.ExcelMcp.Core.Utilities; +using PptMcp.Core.Utilities; using Xunit; -namespace Sbroenne.ExcelMcp.Core.Tests.Unit; +namespace PptMcp.Core.Tests.Unit; /// /// Unit tests for ParameterTransforms.ResolveValuesOrFile and ResolveFormulasOrFile. @@ -11,14 +11,14 @@ namespace Sbroenne.ExcelMcp.Core.Tests.Unit; [Trait("Category", "Unit")] [Trait("Feature", "ParameterTransforms")] [Trait("Speed", "Fast")] -[Trait("RequiresExcel", "false")] +[Trait("RequiresPowerPoint", "false")] public sealed class ParameterTransformsFileTests : IDisposable { private readonly string _tempDir; public ParameterTransformsFileTests() { - _tempDir = Path.Combine(Path.GetTempPath(), $"ExcelMcp_PT_{Guid.NewGuid():N}"); + _tempDir = Path.Combine(Path.GetTempPath(), $"PptMcp_PT_{Guid.NewGuid():N}"); Directory.CreateDirectory(_tempDir); } @@ -235,91 +235,7 @@ public void ResolveValuesOrFile_CustomParameterName_UsedInErrorMessage() Assert.Contains("rowsFile", ex.Message); } - // === ResolveFormulasOrFile: Inline formulas take priority === - - [Fact] - public void ResolveFormulasOrFile_InlineFormulas_ReturnsInlineDirectly() - { - var inline = new List> { new() { "=A1+B1", "=SUM(A:A)" } }; - var result = ParameterTransforms.ResolveFormulasOrFile(inline, null); - - Assert.Same(inline, result); - } - - // === ResolveFormulasOrFile: Neither provided === - - [Fact] - public void ResolveFormulasOrFile_NeitherProvided_ThrowsArgumentException() - { - var ex = Assert.Throws( - () => ParameterTransforms.ResolveFormulasOrFile(null, null)); - - Assert.Contains("formulas", ex.Message); - Assert.Contains("formulasFile", ex.Message); - } - - [Fact] - public void ResolveFormulasOrFile_EmptyListAndNoFile_ThrowsArgumentException() - { - var empty = new List>(); - var ex = Assert.Throws( - () => ParameterTransforms.ResolveFormulasOrFile(empty, null)); - - Assert.Contains("formulas", ex.Message); - } - - // === ResolveFormulasOrFile: File not found === - - [Fact] - public void ResolveFormulasOrFile_FileNotFound_ThrowsFileNotFoundException() - { - var missingPath = Path.Combine(_tempDir, "no_such_file.json"); - - var ex = Assert.Throws( - () => ParameterTransforms.ResolveFormulasOrFile(null, missingPath)); - - Assert.Contains(missingPath, ex.Message); - } - - // === ResolveFormulasOrFile: JSON file === - - [Fact] - public void ResolveFormulasOrFile_JsonFile_Parses2DStringArray() - { - var json = "[[\"=A1+B1\",\"=C1*2\"],[\"=SUM(A:A)\",\"=AVERAGE(B:B)\"]]"; - var path = CreateTempFile("formulas.json", json); - - var result = ParameterTransforms.ResolveFormulasOrFile(null, path); - - Assert.Equal(2, result.Count); - Assert.Equal("=A1+B1", result[0][0]); - Assert.Equal("=C1*2", result[0][1]); - Assert.Equal("=SUM(A:A)", result[1][0]); - Assert.Equal("=AVERAGE(B:B)", result[1][1]); - } - - [Fact] - public void ResolveFormulasOrFile_InvalidJson_ThrowsArgumentException() - { - var path = CreateTempFile("bad_formulas.json", "not json at all"); - - var ex = Assert.Throws( - () => ParameterTransforms.ResolveFormulasOrFile(null, path)); - - Assert.Contains("Invalid JSON", ex.Message); - } - - // === ResolveFormulasOrFile: Custom parameterName === - - [Fact] - public void ResolveFormulasOrFile_CustomParameterName_UsedInErrorMessage() - { - var ex = Assert.Throws( - () => ParameterTransforms.ResolveFormulasOrFile(null, null, "formats")); - - Assert.Contains("formats", ex.Message); - Assert.Contains("formatsFile", ex.Message); - } + // ResolveFormulasOrFile tests removed — Excel-specific formulas not applicable to PowerPoint // === ParseCsvToRows === diff --git a/tests/PptMcp.Core.Tests/Unit/ParameterValidationTests.cs b/tests/PptMcp.Core.Tests/Unit/ParameterValidationTests.cs new file mode 100644 index 00000000..0bf397ff --- /dev/null +++ b/tests/PptMcp.Core.Tests/Unit/ParameterValidationTests.cs @@ -0,0 +1,1044 @@ +using PptMcp.Core.Commands.Animation; +using PptMcp.Core.Commands.Background; +using PptMcp.Core.Commands.Chart; +using PptMcp.Core.Commands.Comment; +using PptMcp.Core.Commands.CustomShow; +using PptMcp.Core.Commands.DocumentProperty; +using PptMcp.Core.Commands.Export; +using PptMcp.Core.Commands.Hyperlink; +using PptMcp.Core.Commands.Media; +using PptMcp.Core.Commands.Section; +using PptMcp.Core.Commands.Shape; +using PptMcp.Core.Commands.ShapeAlign; +using PptMcp.Core.Commands.Slide; +using PptMcp.Core.Commands.SlideImport; +using PptMcp.Core.Commands.SlideTable; +using PptMcp.Core.Commands.SmartArt; +using PptMcp.Core.Commands.Tag; +using PptMcp.Core.Commands.Text; +using PptMcp.Core.Commands.Vba; +using Xunit; + +namespace PptMcp.Core.Tests.Unit; + +/// +/// Tests that Core Commands validate required parameters before executing. +/// These tests verify that ArgumentException/ArgumentNullException is thrown +/// for null/empty required parameters WITHOUT needing a PowerPoint COM connection. +/// +public class ParameterValidationTests +{ + // ── Hyperlink Commands ─────────────────────────────────── + + [Fact] + public void HyperlinkAdd_NullShapeName_ThrowsArgumentNullException() + { + var commands = new HyperlinkCommands(); + Assert.Throws(() => commands.Add(null!, 1, null!, "https://example.com")); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void HyperlinkAdd_EmptyShapeName_ThrowsArgumentException(string shapeName) + { + var commands = new HyperlinkCommands(); + Assert.Throws(() => commands.Add(null!, 1, shapeName, "https://example.com")); + } + + [Fact] + public void HyperlinkAdd_NullAddress_ThrowsArgumentNullException() + { + var commands = new HyperlinkCommands(); + Assert.Throws(() => commands.Add(null!, 1, "Shape1", null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void HyperlinkAdd_EmptyAddress_ThrowsArgumentException(string address) + { + var commands = new HyperlinkCommands(); + Assert.Throws(() => commands.Add(null!, 1, "Shape1", address)); + } + + [Fact] + public void HyperlinkRead_NullShapeName_ThrowsArgumentNullException() + { + var commands = new HyperlinkCommands(); + Assert.Throws(() => commands.Read(null!, 1, null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void HyperlinkRead_EmptyShapeName_ThrowsArgumentException(string shapeName) + { + var commands = new HyperlinkCommands(); + Assert.Throws(() => commands.Read(null!, 1, shapeName)); + } + + // ── VBA Commands ───────────────────────────────────────── + + [Fact] + public void VbaView_NullModuleName_ThrowsArgumentNullException() + { + var commands = new VbaCommands(); + Assert.Throws(() => commands.View(null!, null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void VbaView_EmptyModuleName_ThrowsArgumentException(string moduleName) + { + var commands = new VbaCommands(); + Assert.Throws(() => commands.View(null!, moduleName)); + } + + [Fact] + public void VbaImport_NullModuleName_ThrowsArgumentNullException() + { + var commands = new VbaCommands(); + Assert.Throws(() => commands.Import(null!, null!, "Sub Test()\nEnd Sub", 1)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void VbaImport_EmptyModuleName_ThrowsArgumentException(string moduleName) + { + var commands = new VbaCommands(); + Assert.Throws(() => commands.Import(null!, moduleName, "Sub Test()\nEnd Sub", 1)); + } + + [Fact] + public void VbaImport_NullCode_ThrowsArgumentNullException() + { + var commands = new VbaCommands(); + Assert.Throws(() => commands.Import(null!, "Module1", null!, 1)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void VbaImport_EmptyCode_ThrowsArgumentException(string code) + { + var commands = new VbaCommands(); + Assert.Throws(() => commands.Import(null!, "Module1", code, 1)); + } + + [Fact] + public void VbaDelete_NullModuleName_ThrowsArgumentNullException() + { + var commands = new VbaCommands(); + Assert.Throws(() => commands.Delete(null!, null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void VbaDelete_EmptyModuleName_ThrowsArgumentException(string moduleName) + { + var commands = new VbaCommands(); + Assert.Throws(() => commands.Delete(null!, moduleName)); + } + + [Fact] + public void VbaRun_NullMacroName_ThrowsArgumentNullException() + { + var commands = new VbaCommands(); + Assert.Throws(() => commands.Run(null!, null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void VbaRun_EmptyMacroName_ThrowsArgumentException(string macroName) + { + var commands = new VbaCommands(); + Assert.Throws(() => commands.Run(null!, macroName)); + } + + // ── Section Commands ───────────────────────────────────── + + [Fact] + public void SectionAdd_NullSectionName_ThrowsArgumentNullException() + { + var commands = new SectionCommands(); + Assert.Throws(() => commands.Add(null!, null!, 1)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void SectionAdd_EmptySectionName_ThrowsArgumentException(string sectionName) + { + var commands = new SectionCommands(); + Assert.Throws(() => commands.Add(null!, sectionName, 1)); + } + + [Fact] + public void SectionRename_NullNewName_ThrowsArgumentNullException() + { + var commands = new SectionCommands(); + Assert.Throws(() => commands.Rename(null!, 1, null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void SectionRename_EmptyNewName_ThrowsArgumentException(string newName) + { + var commands = new SectionCommands(); + Assert.Throws(() => commands.Rename(null!, 1, newName)); + } + + // ── Animation Commands ─────────────────────────────────── + + [Fact] + public void AnimationAdd_NullShapeName_ThrowsArgumentNullException() + { + var commands = new AnimationCommands(); + Assert.Throws(() => commands.Add(null!, 1, null!, 1, 1)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void AnimationAdd_EmptyShapeName_ThrowsArgumentException(string shapeName) + { + var commands = new AnimationCommands(); + Assert.Throws(() => commands.Add(null!, 1, shapeName, 1, 1)); + } + + // ── Chart Commands ─────────────────────────────────────── + + [Fact] + public void ChartGetInfo_NullShapeName_ThrowsArgumentNullException() + { + var commands = new ChartCommands(); + Assert.Throws(() => commands.GetInfo(null!, 1, null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ChartGetInfo_EmptyShapeName_ThrowsArgumentException(string shapeName) + { + var commands = new ChartCommands(); + Assert.Throws(() => commands.GetInfo(null!, 1, shapeName)); + } + + [Fact] + public void ChartSetTitle_NullShapeName_ThrowsArgumentNullException() + { + var commands = new ChartCommands(); + Assert.Throws(() => commands.SetTitle(null!, 1, null!, "Title")); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ChartSetTitle_EmptyShapeName_ThrowsArgumentException(string shapeName) + { + var commands = new ChartCommands(); + Assert.Throws(() => commands.SetTitle(null!, 1, shapeName, "Title")); + } + + [Fact] + public void ChartSetType_NullShapeName_ThrowsArgumentNullException() + { + var commands = new ChartCommands(); + Assert.Throws(() => commands.SetType(null!, 1, null!, 1)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ChartSetType_EmptyShapeName_ThrowsArgumentException(string shapeName) + { + var commands = new ChartCommands(); + Assert.Throws(() => commands.SetType(null!, 1, shapeName, 1)); + } + + [Fact] + public void ChartDelete_NullShapeName_ThrowsArgumentNullException() + { + var commands = new ChartCommands(); + Assert.Throws(() => commands.Delete(null!, 1, null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ChartDelete_EmptyShapeName_ThrowsArgumentException(string shapeName) + { + var commands = new ChartCommands(); + Assert.Throws(() => commands.Delete(null!, 1, shapeName)); + } + + // ── Export Commands ────────────────────────────────────── + + [Fact] + public void ExportToPdf_NullDestinationPath_ThrowsArgumentNullException() + { + var commands = new ExportCommands(); + Assert.Throws(() => commands.ToPdf(null!, null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ExportToPdf_EmptyDestinationPath_ThrowsArgumentException(string path) + { + var commands = new ExportCommands(); + Assert.Throws(() => commands.ToPdf(null!, path)); + } + + [Fact] + public void ExportSlideToImage_NullDestinationPath_ThrowsArgumentNullException() + { + var commands = new ExportCommands(); + Assert.Throws(() => commands.SlideToImage(null!, 1, null!, 1920, 1080)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ExportSlideToImage_EmptyDestinationPath_ThrowsArgumentException(string path) + { + var commands = new ExportCommands(); + Assert.Throws(() => commands.SlideToImage(null!, 1, path, 1920, 1080)); + } + + [Fact] + public void ExportSaveAs_NullDestinationPath_ThrowsArgumentNullException() + { + var commands = new ExportCommands(); + Assert.Throws(() => commands.SaveAs(null!, null!, 1)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ExportSaveAs_EmptyDestinationPath_ThrowsArgumentException(string path) + { + var commands = new ExportCommands(); + Assert.Throws(() => commands.SaveAs(null!, path, 1)); + } + + // ── Shape Commands ─────────────────────────────────────── + + [Fact] + public void ShapeRead_NullShapeName_ThrowsArgumentNullException() + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.Read(null!, 1, null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ShapeRead_EmptyShapeName_ThrowsArgumentException(string shapeName) + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.Read(null!, 1, shapeName)); + } + + [Fact] + public void ShapeMoveResize_NullShapeName_ThrowsArgumentNullException() + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.MoveResize(null!, 1, null!, 0, 0, null, null)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ShapeMoveResize_EmptyShapeName_ThrowsArgumentException(string shapeName) + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.MoveResize(null!, 1, shapeName, 0, 0, null, null)); + } + + [Fact] + public void ShapeDelete_NullShapeName_ThrowsArgumentNullException() + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.Delete(null!, 1, null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ShapeDelete_EmptyShapeName_ThrowsArgumentException(string shapeName) + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.Delete(null!, 1, shapeName)); + } + + [Fact] + public void ShapeZOrder_NullShapeName_ThrowsArgumentNullException() + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.ZOrder(null!, 1, null!, 0)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ShapeZOrder_EmptyShapeName_ThrowsArgumentException(string shapeName) + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.ZOrder(null!, 1, shapeName, 0)); + } + + [Fact] + public void ShapeSetFill_NullShapeName_ThrowsArgumentNullException() + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.SetFill(null!, 1, null!, "#FF0000")); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ShapeSetFill_EmptyShapeName_ThrowsArgumentException(string shapeName) + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.SetFill(null!, 1, shapeName, "#FF0000")); + } + + [Fact] + public void ShapeSetFill_NullColorHex_ThrowsArgumentNullException() + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.SetFill(null!, 1, "Shape1", null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ShapeSetFill_EmptyColorHex_ThrowsArgumentException(string colorHex) + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.SetFill(null!, 1, "Shape1", colorHex)); + } + + [Fact] + public void ShapeSetLine_NullShapeName_ThrowsArgumentNullException() + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.SetLine(null!, 1, null!, "#FF0000", 1f)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ShapeSetLine_EmptyShapeName_ThrowsArgumentException(string shapeName) + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.SetLine(null!, 1, shapeName, "#FF0000", 1f)); + } + + [Fact] + public void ShapeSetLine_NullColorHex_ThrowsArgumentNullException() + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.SetLine(null!, 1, "Shape1", null!, 1f)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ShapeSetLine_EmptyColorHex_ThrowsArgumentException(string colorHex) + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.SetLine(null!, 1, "Shape1", colorHex, 1f)); + } + + [Fact] + public void ShapeSetRotation_NullShapeName_ThrowsArgumentNullException() + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.SetRotation(null!, 1, null!, 45f)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ShapeSetRotation_EmptyShapeName_ThrowsArgumentException(string shapeName) + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.SetRotation(null!, 1, shapeName, 45f)); + } + + [Fact] + public void ShapeGroup_NullShapeNames_ThrowsArgumentNullException() + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.Group(null!, 1, null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ShapeGroup_EmptyShapeNames_ThrowsArgumentException(string shapeNames) + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.Group(null!, 1, shapeNames)); + } + + [Fact] + public void ShapeUngroup_NullShapeName_ThrowsArgumentNullException() + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.Ungroup(null!, 1, null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ShapeUngroup_EmptyShapeName_ThrowsArgumentException(string shapeName) + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.Ungroup(null!, 1, shapeName)); + } + + [Fact] + public void ShapeSetAltText_NullShapeName_ThrowsArgumentNullException() + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.SetAltText(null!, 1, null!, "alt text")); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ShapeSetAltText_EmptyShapeName_ThrowsArgumentException(string shapeName) + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.SetAltText(null!, 1, shapeName, "alt text")); + } + + [Fact] + public void ShapeCopyToSlide_NullShapeName_ThrowsArgumentNullException() + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.CopyToSlide(null!, 1, null!, 2)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ShapeCopyToSlide_EmptyShapeName_ThrowsArgumentException(string shapeName) + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.CopyToSlide(null!, 1, shapeName, 2)); + } + + [Fact] + public void ShapeSetShadow_NullShapeName_ThrowsArgumentNullException() + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.SetShadow(null!, 1, null!, true, 3f, 3f)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ShapeSetShadow_EmptyShapeName_ThrowsArgumentException(string shapeName) + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.SetShadow(null!, 1, shapeName, true, 3f, 3f)); + } + + [Fact] + public void ShapeAddConnector_NullStartShapeName_ThrowsArgumentNullException() + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.AddConnector(null!, 1, 1, null!, "End")); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ShapeAddConnector_EmptyStartShapeName_ThrowsArgumentException(string startShapeName) + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.AddConnector(null!, 1, 1, startShapeName, "End")); + } + + [Fact] + public void ShapeAddConnector_NullEndShapeName_ThrowsArgumentNullException() + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.AddConnector(null!, 1, 1, "Start", null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ShapeAddConnector_EmptyEndShapeName_ThrowsArgumentException(string endShapeName) + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.AddConnector(null!, 1, 1, "Start", endShapeName)); + } + + [Fact] + public void ShapeMergeShapes_NullShapeNames_ThrowsArgumentNullException() + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.MergeShapes(null!, 1, null!, 1)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ShapeMergeShapes_EmptyShapeNames_ThrowsArgumentException(string shapeNames) + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.MergeShapes(null!, 1, shapeNames, 1)); + } + + [Fact] + public void ShapeDuplicate_NullShapeName_ThrowsArgumentNullException() + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.Duplicate(null!, 1, null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ShapeDuplicate_EmptyShapeName_ThrowsArgumentException(string shapeName) + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.Duplicate(null!, 1, shapeName)); + } + + [Fact] + public void ShapeFlip_NullShapeName_ThrowsArgumentNullException() + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.Flip(null!, 1, null!, 0)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ShapeFlip_EmptyShapeName_ThrowsArgumentException(string shapeName) + { + var commands = new ShapeCommands(); + Assert.Throws(() => commands.Flip(null!, 1, shapeName, 0)); + } + + // ── Text Commands ──────────────────────────────────────── + + [Fact] + public void TextGetText_NullShapeName_ThrowsArgumentNullException() + { + var commands = new TextCommands(); + Assert.Throws(() => commands.GetText(null!, 1, null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void TextGetText_EmptyShapeName_ThrowsArgumentException(string shapeName) + { + var commands = new TextCommands(); + Assert.Throws(() => commands.GetText(null!, 1, shapeName)); + } + + [Fact] + public void TextSetText_NullShapeName_ThrowsArgumentNullException() + { + var commands = new TextCommands(); + Assert.Throws(() => commands.SetText(null!, 1, null!, "text")); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void TextSetText_EmptyShapeName_ThrowsArgumentException(string shapeName) + { + var commands = new TextCommands(); + Assert.Throws(() => commands.SetText(null!, 1, shapeName, "text")); + } + + [Fact] + public void TextFormat_NullShapeName_ThrowsArgumentNullException() + { + var commands = new TextCommands(); + Assert.Throws(() => commands.Format(null!, 1, null!, null, null, null, null, null, null, null)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void TextFormat_EmptyShapeName_ThrowsArgumentException(string shapeName) + { + var commands = new TextCommands(); + Assert.Throws(() => commands.Format(null!, 1, shapeName, null, null, null, null, null, null, null)); + } + + [Fact] + public void TextFormatAdvanced_NullShapeName_ThrowsArgumentNullException() + { + var commands = new TextCommands(); + Assert.Throws(() => commands.FormatAdvanced(null!, 1, null!, null, null, null, null)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void TextFormatAdvanced_EmptyShapeName_ThrowsArgumentException(string shapeName) + { + var commands = new TextCommands(); + Assert.Throws(() => commands.FormatAdvanced(null!, 1, shapeName, null, null, null, null)); + } + + // ── Background Commands ────────────────────────────────── + + [Fact] + public void BackgroundSetColor_NullColorHex_ThrowsArgumentNullException() + { + var commands = new BackgroundCommands(); + Assert.Throws(() => commands.SetColor(null!, 1, null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void BackgroundSetColor_EmptyColorHex_ThrowsArgumentException(string colorHex) + { + var commands = new BackgroundCommands(); + Assert.Throws(() => commands.SetColor(null!, 1, colorHex)); + } + + [Fact] + public void BackgroundSetImage_NullImagePath_ThrowsArgumentNullException() + { + var commands = new BackgroundCommands(); + Assert.Throws(() => commands.SetImage(null!, 1, null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void BackgroundSetImage_EmptyImagePath_ThrowsArgumentException(string imagePath) + { + var commands = new BackgroundCommands(); + Assert.Throws(() => commands.SetImage(null!, 1, imagePath)); + } + + // ── SmartArt Commands ──────────────────────────────────── + + [Fact] + public void SmartArtGetInfo_NullShapeName_ThrowsArgumentNullException() + { + var commands = new SmartArtCommands(); + Assert.Throws(() => commands.GetInfo(null!, 1, null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void SmartArtGetInfo_EmptyShapeName_ThrowsArgumentException(string shapeName) + { + var commands = new SmartArtCommands(); + Assert.Throws(() => commands.GetInfo(null!, 1, shapeName)); + } + + [Fact] + public void SmartArtAddNode_NullShapeName_ThrowsArgumentNullException() + { + var commands = new SmartArtCommands(); + Assert.Throws(() => commands.AddNode(null!, 1, null!, "text")); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void SmartArtAddNode_EmptyShapeName_ThrowsArgumentException(string shapeName) + { + var commands = new SmartArtCommands(); + Assert.Throws(() => commands.AddNode(null!, 1, shapeName, "text")); + } + + [Fact] + public void SmartArtAddNode_NullText_ThrowsArgumentNullException() + { + var commands = new SmartArtCommands(); + Assert.Throws(() => commands.AddNode(null!, 1, "SmartArt1", null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void SmartArtAddNode_EmptyText_ThrowsArgumentException(string text) + { + var commands = new SmartArtCommands(); + Assert.Throws(() => commands.AddNode(null!, 1, "SmartArt1", text)); + } + + // ── Comment Commands ───────────────────────────────────── + + [Fact] + public void CommentAdd_NullText_ThrowsArgumentNullException() + { + var commands = new CommentCommands(); + Assert.Throws(() => commands.Add(null!, 1, null!, "Author", 0, 0)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void CommentAdd_EmptyText_ThrowsArgumentException(string text) + { + var commands = new CommentCommands(); + Assert.Throws(() => commands.Add(null!, 1, text, "Author", 0, 0)); + } + + [Fact] + public void CommentAdd_NullAuthor_ThrowsArgumentNullException() + { + var commands = new CommentCommands(); + Assert.Throws(() => commands.Add(null!, 1, "Comment text", null!, 0, 0)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void CommentAdd_EmptyAuthor_ThrowsArgumentException(string author) + { + var commands = new CommentCommands(); + Assert.Throws(() => commands.Add(null!, 1, "Comment text", author, 0, 0)); + } + + // ── Custom Show Commands ───────────────────────────────── + + [Fact] + public void CustomShowCreate_NullShowName_ThrowsArgumentNullException() + { + var commands = new CustomShowCommands(); + Assert.Throws(() => commands.Create(null!, null!, "1,2,3")); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void CustomShowCreate_EmptyShowName_ThrowsArgumentException(string showName) + { + var commands = new CustomShowCommands(); + Assert.Throws(() => commands.Create(null!, showName, "1,2,3")); + } + + [Fact] + public void CustomShowCreate_NullSlideIndices_ThrowsArgumentNullException() + { + var commands = new CustomShowCommands(); + Assert.Throws(() => commands.Create(null!, "Show1", null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void CustomShowCreate_EmptySlideIndices_ThrowsArgumentException(string slideIndices) + { + var commands = new CustomShowCommands(); + Assert.Throws(() => commands.Create(null!, "Show1", slideIndices)); + } + + [Fact] + public void CustomShowDelete_NullShowName_ThrowsArgumentNullException() + { + var commands = new CustomShowCommands(); + Assert.Throws(() => commands.Delete(null!, null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void CustomShowDelete_EmptyShowName_ThrowsArgumentException(string showName) + { + var commands = new CustomShowCommands(); + Assert.Throws(() => commands.Delete(null!, showName)); + } + + // ── Tag Commands ───────────────────────────────────────── + + [Fact] + public void TagSetTag_NullTagName_ThrowsArgumentNullException() + { + var commands = new TagCommands(); + Assert.Throws(() => commands.SetTag(null!, 1, null, null!, "value")); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void TagSetTag_EmptyTagName_ThrowsArgumentException(string tagName) + { + var commands = new TagCommands(); + Assert.Throws(() => commands.SetTag(null!, 1, null, tagName, "value")); + } + + [Fact] + public void TagDeleteTag_NullTagName_ThrowsArgumentNullException() + { + var commands = new TagCommands(); + Assert.Throws(() => commands.DeleteTag(null!, 1, null, null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void TagDeleteTag_EmptyTagName_ThrowsArgumentException(string tagName) + { + var commands = new TagCommands(); + Assert.Throws(() => commands.DeleteTag(null!, 1, null, tagName)); + } + + // ── Slide Import Commands ──────────────────────────────── + + [Fact] + public void SlideImportImportSlides_NullSourceFilePath_ThrowsArgumentNullException() + { + var commands = new SlideImportCommands(); + Assert.Throws(() => commands.ImportSlides(null!, null!, "1,2", 1)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void SlideImportImportSlides_EmptySourceFilePath_ThrowsArgumentException(string sourceFilePath) + { + var commands = new SlideImportCommands(); + Assert.Throws(() => commands.ImportSlides(null!, sourceFilePath, "1,2", 1)); + } + + // ── Media Commands ─────────────────────────────────────── + + [Fact] + public void MediaGetInfo_NullShapeName_ThrowsArgumentNullException() + { + var commands = new MediaCommands(); + Assert.Throws(() => commands.GetInfo(null!, 1, null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void MediaGetInfo_EmptyShapeName_ThrowsArgumentException(string shapeName) + { + var commands = new MediaCommands(); + Assert.Throws(() => commands.GetInfo(null!, 1, shapeName)); + } + + // ── Slide Commands ─────────────────────────────────────── + + [Fact] + public void SlideSetName_NullName_ThrowsArgumentNullException() + { + var commands = new SlideCommands(); + Assert.Throws(() => commands.SetName(null!, 1, null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void SlideSetName_EmptyName_ThrowsArgumentException(string name) + { + var commands = new SlideCommands(); + Assert.Throws(() => commands.SetName(null!, 1, name)); + } + + // ── Document Property Commands ─────────────────────────── + + [Fact] + public void DocumentPropertyGetCustom_NullPropertyName_ThrowsArgumentNullException() + { + var commands = new DocumentPropertyCommands(); + Assert.Throws(() => commands.GetCustom(null!, null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void DocumentPropertyGetCustom_EmptyPropertyName_ThrowsArgumentException(string propertyName) + { + var commands = new DocumentPropertyCommands(); + Assert.Throws(() => commands.GetCustom(null!, propertyName)); + } + + [Fact] + public void DocumentPropertySetCustom_NullPropertyName_ThrowsArgumentNullException() + { + var commands = new DocumentPropertyCommands(); + Assert.Throws(() => commands.SetCustom(null!, null!, "value")); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void DocumentPropertySetCustom_EmptyPropertyName_ThrowsArgumentException(string propertyName) + { + var commands = new DocumentPropertyCommands(); + Assert.Throws(() => commands.SetCustom(null!, propertyName, "value")); + } + + // ── Shape Align Commands ───────────────────────────────── + + [Fact] + public void ShapeAlignAlign_NullShapeNames_ThrowsArgumentNullException() + { + var commands = new ShapeAlignCommands(); + Assert.Throws(() => commands.Align(null!, 1, null!, 1)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ShapeAlignAlign_EmptyShapeNames_ThrowsArgumentException(string shapeNames) + { + var commands = new ShapeAlignCommands(); + Assert.Throws(() => commands.Align(null!, 1, shapeNames, 1)); + } + + [Fact] + public void ShapeAlignDistribute_NullShapeNames_ThrowsArgumentNullException() + { + var commands = new ShapeAlignCommands(); + Assert.Throws(() => commands.Distribute(null!, 1, null!, 0)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ShapeAlignDistribute_EmptyShapeNames_ThrowsArgumentException(string shapeNames) + { + var commands = new ShapeAlignCommands(); + Assert.Throws(() => commands.Distribute(null!, 1, shapeNames, 0)); + } + + // ── Slide Table Commands ───────────────────────────────── + + [Fact] + public void SlideTableFormatCell_NullShapeName_ThrowsArgumentNullException() + { + var commands = new SlideTableCommands(); + Assert.Throws(() => commands.FormatCell(null!, 1, null!, 1, 1, null, null, 0, null)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void SlideTableFormatCell_EmptyShapeName_ThrowsArgumentException(string shapeName) + { + var commands = new SlideTableCommands(); + Assert.Throws(() => commands.FormatCell(null!, 1, shapeName, 1, 1, null, null, 0, null)); + } +} diff --git a/tests/PptMcp.Core.Tests/Unit/ResultTypeInvariantTests.cs b/tests/PptMcp.Core.Tests/Unit/ResultTypeInvariantTests.cs new file mode 100644 index 00000000..32c46711 --- /dev/null +++ b/tests/PptMcp.Core.Tests/Unit/ResultTypeInvariantTests.cs @@ -0,0 +1,290 @@ +using PptMcp.Core.Models; +using Xunit; + +namespace PptMcp.Core.Tests.Unit; + +/// +/// Validates invariants on result types to prevent Rule 1 violations +/// (Success=true with ErrorMessage set). +/// +public class ResultTypeInvariantTests +{ + [Fact] + public void OperationResult_DefaultState_SuccessIsFalse() + { + var result = new OperationResult(); + Assert.False(result.Success); + Assert.Null(result.ErrorMessage); + } + + [Fact] + public void OperationResult_SuccessTrue_ErrorMessageMustBeNull() + { + var result = new OperationResult { Success = true }; + Assert.True(result.Success); + Assert.Null(result.ErrorMessage); + } + + [Fact] + public void SlideListResult_DefaultState_EmptySlidesList() + { + var result = new SlideListResult(); + Assert.False(result.Success); + Assert.NotNull(result.Slides); + Assert.Empty(result.Slides); + } + + [Fact] + public void ShapeListResult_DefaultState_EmptyShapesList() + { + var result = new ShapeListResult(); + Assert.False(result.Success); + Assert.NotNull(result.Shapes); + Assert.Empty(result.Shapes); + } + + [Fact] + public void TextResult_DefaultState_EmptyText() + { + var result = new TextResult(); + Assert.False(result.Success); + Assert.Equal(string.Empty, result.Text); + Assert.NotNull(result.Paragraphs); + Assert.Empty(result.Paragraphs); + } + + [Fact] + public void SlideInfo_DefaultValues_AreReasonable() + { + var info = new SlideInfo(); + Assert.Equal(0, info.SlideIndex); + Assert.Equal(0, info.SlideNumber); + Assert.Equal(string.Empty, info.SlideId); + Assert.Equal(string.Empty, info.LayoutName); + Assert.Equal(string.Empty, info.MasterName); + Assert.Equal(0, info.ShapeCount); + Assert.False(info.HasNotes); + Assert.False(info.HasAnimations); + Assert.Null(info.Name); + } + + [Fact] + public void ShapeInfo_DefaultValues_AreReasonable() + { + var info = new ShapeInfo(); + Assert.Equal(0, info.ShapeId); + Assert.Equal(string.Empty, info.Name); + Assert.Equal(string.Empty, info.ShapeType); + Assert.Equal(0f, info.Left); + Assert.Equal(0f, info.Top); + Assert.Equal(0f, info.Width); + Assert.Equal(0f, info.Height); + Assert.False(info.HasTextFrame); + Assert.False(info.HasTable); + Assert.False(info.HasChart); + Assert.False(info.IsGroup); + Assert.False(info.IsPlaceholder); + Assert.Null(info.Text); + Assert.Null(info.AlternativeText); + Assert.Null(info.PlaceholderType); + Assert.Null(info.GroupItems); + } + + [Fact] + public void RenameResult_DefaultValues() + { + var result = new RenameResult(); + Assert.False(result.Success); + Assert.Equal(string.Empty, result.ObjectType); + Assert.Equal(string.Empty, result.OldName); + Assert.Equal(string.Empty, result.NewName); + } + + [Fact] + public void ExportResult_DefaultValues() + { + var result = new ExportResult(); + Assert.False(result.Success); + Assert.Equal(string.Empty, result.OutputPath); + Assert.Equal(string.Empty, result.Format); + } + + [Fact] + public void ChartInfoResult_DefaultValues() + { + var result = new ChartInfoResult(); + Assert.False(result.Success); + Assert.Equal(string.Empty, result.ShapeName); + Assert.Equal(string.Empty, result.ChartTypeName); + Assert.Null(result.Title); + Assert.False(result.HasLegend); + Assert.Equal(0, result.SeriesCount); + } + + [Fact] + public void VbaModuleListResult_DefaultValues() + { + var result = new VbaModuleListResult(); + Assert.False(result.Success); + Assert.NotNull(result.Modules); + Assert.Empty(result.Modules); + } + + [Fact] + public void DocumentPropertyResult_AllPropertiesNullByDefault() + { + var result = new DocumentPropertyResult(); + Assert.False(result.Success); + Assert.Null(result.Title); + Assert.Null(result.Subject); + Assert.Null(result.Author); + Assert.Null(result.Keywords); + Assert.Null(result.Comments); + Assert.Null(result.Company); + Assert.Null(result.Category); + } + + [Fact] + public void SectionListResult_DefaultValues() + { + var result = new SectionListResult(); + Assert.False(result.Success); + Assert.NotNull(result.Sections); + Assert.Empty(result.Sections); + } + + [Fact] + public void AnimationListResult_DefaultValues() + { + var result = new AnimationListResult(); + Assert.False(result.Success); + Assert.NotNull(result.Animations); + Assert.Empty(result.Animations); + } + + [Fact] + public void HyperlinkListResult_DefaultValues() + { + var result = new HyperlinkListResult(); + Assert.False(result.Success); + Assert.NotNull(result.Hyperlinks); + Assert.Empty(result.Hyperlinks); + } + + [Fact] + public void MasterListResult_DefaultValues() + { + var result = new MasterListResult(); + Assert.False(result.Success); + Assert.NotNull(result.Masters); + Assert.Empty(result.Masters); + } + + [Fact] + public void DesignListResult_DefaultValues() + { + var result = new DesignListResult(); + Assert.False(result.Success); + Assert.NotNull(result.Designs); + Assert.Empty(result.Designs); + } + + [Fact] + public void FileValidationInfo_DefaultValues() + { + var result = new FileValidationInfo(); + Assert.False(result.Success); + Assert.False(result.Exists); + Assert.Equal(string.Empty, result.FileName); + Assert.Equal(0, result.FileSizeBytes); + Assert.False(result.IsReadOnly); + Assert.False(result.IsMacroEnabled); + Assert.Equal(0, result.SlideCount); + } + + [Fact] + public void CommentListResult_DefaultValues() + { + var result = new CommentListResult(); + Assert.False(result.Success); + Assert.NotNull(result.Comments); + Assert.Empty(result.Comments); + } + + [Fact] + public void PlaceholderListResult_DefaultValues() + { + var result = new PlaceholderListResult(); + Assert.False(result.Success); + Assert.NotNull(result.Placeholders); + Assert.Empty(result.Placeholders); + Assert.Equal(0, result.SlideIndex); + } + + [Fact] + public void BackgroundResult_DefaultValues() + { + var result = new BackgroundResult(); + Assert.False(result.Success); + Assert.False(result.FollowMasterBackground); + Assert.Equal(string.Empty, result.FillType); + } + + [Fact] + public void HeaderFooterResult_DefaultValues() + { + var result = new HeaderFooterResult(); + Assert.False(result.Success); + Assert.False(result.ShowFooter); + Assert.False(result.ShowSlideNumber); + Assert.False(result.ShowDate); + Assert.Null(result.FooterText); + } + + [Fact] + public void SmartArtInfoResult_DefaultValues() + { + var result = new SmartArtInfoResult(); + Assert.False(result.Success); + Assert.NotNull(result.Nodes); + Assert.Empty(result.Nodes); + Assert.Equal(string.Empty, result.LayoutName); + } + + [Fact] + public void CustomShowListResult_DefaultValues() + { + var result = new CustomShowListResult(); + Assert.False(result.Success); + Assert.NotNull(result.Shows); + Assert.Empty(result.Shows); + } + + [Fact] + public void PageSetupResult_DefaultValues() + { + var result = new PageSetupResult(); + Assert.False(result.Success); + Assert.Equal(0f, result.SlideWidth); + Assert.Equal(0f, result.SlideHeight); + } + + [Fact] + public void TagListResult_DefaultValues() + { + var result = new TagListResult(); + Assert.False(result.Success); + Assert.NotNull(result.Tags); + Assert.Empty(result.Tags); + Assert.Null(result.ShapeName); + } + + [Fact] + public void ColorSchemeListResult_DefaultValues() + { + var result = new ColorSchemeListResult(); + Assert.False(result.Success); + Assert.NotNull(result.ColorSchemes); + Assert.Empty(result.ColorSchemes); + } +} diff --git a/tests/PptMcp.Core.Tests/Unit/ResultTypeSerializationTests.cs b/tests/PptMcp.Core.Tests/Unit/ResultTypeSerializationTests.cs new file mode 100644 index 00000000..bf84a583 --- /dev/null +++ b/tests/PptMcp.Core.Tests/Unit/ResultTypeSerializationTests.cs @@ -0,0 +1,166 @@ +using System.Text.Json; +using PptMcp.Core.Models; +using Xunit; + +namespace PptMcp.Core.Tests.Unit; + +/// +/// Validates JSON serialization behavior of result types, +/// ensuring null properties are omitted and camelCase naming works correctly. +/// +public class ResultTypeSerializationTests +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false + }; + + [Fact] + public void OperationResult_Success_OmitsNullFields() + { + var result = new OperationResult { Success = true, Action = "create", Message = "Done" }; + var json = JsonSerializer.Serialize(result, JsonOptions); + + Assert.Contains("\"success\":true", json); + Assert.Contains("\"action\":\"create\"", json); + Assert.DoesNotContain("errorMessage", json); + Assert.DoesNotContain("filePath", json); + } + + [Fact] + public void OperationResult_Failure_IncludesErrorMessage() + { + var result = new OperationResult { Success = false, ErrorMessage = "Not found" }; + var json = JsonSerializer.Serialize(result, JsonOptions); + + Assert.Contains("\"success\":false", json); + Assert.Contains("\"errorMessage\":\"Not found\"", json); + } + + [Fact] + public void SlideListResult_WithSlides_SerializesCorrectly() + { + var result = new SlideListResult + { + Success = true, + Slides = + [ + new SlideInfo + { + SlideIndex = 1, + SlideNumber = 1, + SlideId = "256", + LayoutName = "Title Slide", + MasterName = "Office Theme", + ShapeCount = 3 + } + ] + }; + var json = JsonSerializer.Serialize(result, JsonOptions); + + Assert.Contains("\"slideIndex\":1", json); + Assert.Contains("\"layoutName\":\"Title Slide\"", json); + Assert.Contains("\"shapeCount\":3", json); + } + + [Fact] + public void ShapeInfo_NullOptionalFields_AreOmitted() + { + var info = new ShapeInfo + { + ShapeId = 1, + Name = "Rectangle 1", + ShapeType = "AutoShape", + Width = 100f, + Height = 50f + }; + var json = JsonSerializer.Serialize(info, JsonOptions); + + Assert.Contains("\"name\":\"Rectangle 1\"", json); + Assert.DoesNotContain("\"text\":", json); + Assert.DoesNotContain("\"alternativeText\":", json); + Assert.DoesNotContain("\"placeholderType\":", json); + Assert.DoesNotContain("\"groupItems\":", json); + } + + [Fact] + public void TextResult_WithParagraphs_SerializesNestedStructure() + { + var result = new TextResult + { + Success = true, + ShapeId = 1, + ShapeName = "Title 1", + Text = "Hello World", + Paragraphs = + [ + new TextParagraphInfo + { + Index = 0, + Text = "Hello World", + Runs = + [ + new TextRunInfo { Text = "Hello ", Bold = true, FontSize = 24f }, + new TextRunInfo { Text = "World", Italic = true } + ] + } + ] + }; + var json = JsonSerializer.Serialize(result, JsonOptions); + + Assert.Contains("\"bold\":true", json); + Assert.Contains("\"fontSize\":24", json); + Assert.Contains("\"italic\":true", json); + } + + [Fact] + public void OperationResult_RoundTrip_PreservesAllFields() + { + var original = new OperationResult + { + Success = true, + Action = "delete", + Message = "Deleted slide 3", + FilePath = @"C:\test\pres.pptx" + }; + + var json = JsonSerializer.Serialize(original, JsonOptions); + var deserialized = JsonSerializer.Deserialize(json, JsonOptions); + + Assert.NotNull(deserialized); + Assert.Equal(original.Success, deserialized.Success); + Assert.Equal(original.Action, deserialized.Action); + Assert.Equal(original.Message, deserialized.Message); + Assert.Equal(original.FilePath, deserialized.FilePath); + } + + [Fact] + public void DocumentPropertyResult_AllNulls_MinimalJson() + { + var result = new DocumentPropertyResult { Success = true }; + var json = JsonSerializer.Serialize(result, JsonOptions); + + // Should only have success, no null property fields + Assert.Contains("\"success\":true", json); + Assert.DoesNotContain("\"title\":", json); + Assert.DoesNotContain("\"author\":", json); + Assert.DoesNotContain("\"subject\":", json); + } + + [Fact] + public void HyperlinkInfo_ConditionalSerialization_WhenWritingDefault() + { + var info = new HyperlinkInfo + { + Index = 1, + Address = "https://example.com" + }; + var json = JsonSerializer.Serialize(info, JsonOptions); + + Assert.Contains("\"address\":\"https://example.com\"", json); + // SlideIndex = 0 should be omitted (WhenWritingDefault) + Assert.DoesNotContain("\"slideIndex\":", json); + } +} diff --git a/tests/ExcelMcp.Core.Tests/Unit/ServiceRegistryJsonParsingTests.cs b/tests/PptMcp.Core.Tests/Unit/ServiceRegistryJsonParsingTests.cs similarity index 97% rename from tests/ExcelMcp.Core.Tests/Unit/ServiceRegistryJsonParsingTests.cs rename to tests/PptMcp.Core.Tests/Unit/ServiceRegistryJsonParsingTests.cs index 79739ad6..b6e8c4a5 100644 --- a/tests/ExcelMcp.Core.Tests/Unit/ServiceRegistryJsonParsingTests.cs +++ b/tests/PptMcp.Core.Tests/Unit/ServiceRegistryJsonParsingTests.cs @@ -1,8 +1,8 @@ using System.Text.Json; -using Sbroenne.ExcelMcp.Generated; +using PptMcp.Generated; using Xunit; -namespace Sbroenne.ExcelMcp.Core.Tests.Unit; +namespace PptMcp.Core.Tests.Unit; /// /// Unit tests for ServiceRegistry.DeserializeNestedCollection (generated helper). @@ -14,7 +14,7 @@ namespace Sbroenne.ExcelMcp.Core.Tests.Unit; [Trait("Category", "Unit")] [Trait("Feature", "ServiceRegistry")] [Trait("Speed", "Fast")] -[Trait("RequiresExcel", "false")] +[Trait("RequiresPowerPoint", "false")] public sealed class ServiceRegistryJsonParsingTests { private static readonly System.Type _registryType = typeof(ServiceRegistry); diff --git a/tests/PptMcp.Core.Tests/Unit/ShapeHelpersTests.cs b/tests/PptMcp.Core.Tests/Unit/ShapeHelpersTests.cs new file mode 100644 index 00000000..e26f6b4e --- /dev/null +++ b/tests/PptMcp.Core.Tests/Unit/ShapeHelpersTests.cs @@ -0,0 +1,70 @@ +using PptMcp.Core.Commands.Slide; +using Xunit; + +namespace PptMcp.Core.Tests.Unit; + +/// +/// Unit tests for ShapeHelpers utility methods. +/// +public class ShapeHelpersTests +{ + [Theory] + [InlineData(1, "AutoShape")] + [InlineData(2, "Callout")] + [InlineData(3, "Chart")] + [InlineData(4, "Comment")] + [InlineData(5, "FreeForm")] + [InlineData(6, "Group")] + [InlineData(7, "EmbeddedOLEObject")] + [InlineData(8, "FormControl")] + [InlineData(9, "Line")] + [InlineData(10, "LinkedOLEObject")] + [InlineData(11, "LinkedPicture")] + [InlineData(12, "OLEControlObject")] + [InlineData(13, "Picture")] + [InlineData(14, "Placeholder")] + [InlineData(15, "TextEffect")] + [InlineData(16, "MediaObject")] + [InlineData(17, "TextBox")] + [InlineData(19, "Table")] + [InlineData(20, "Canvas")] + [InlineData(21, "Diagram")] + [InlineData(22, "Ink")] + [InlineData(23, "InkComment")] + [InlineData(24, "SmartArt")] + [InlineData(25, "Slicer")] + [InlineData(26, "WebVideo")] + [InlineData(27, "ContentApp")] + [InlineData(28, "Graphic")] + [InlineData(29, "LinkedGraphic")] + [InlineData(30, "3DModel")] + [InlineData(31, "Linked3DModel")] + public void GetShapeTypeName_KnownTypes_ReturnsExpectedName(int msoType, string expectedName) + { + var result = ShapeHelpers.GetShapeTypeName(msoType); + Assert.Equal(expectedName, result); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(18)] + [InlineData(32)] + [InlineData(100)] + [InlineData(999)] + public void GetShapeTypeName_UnknownTypes_ReturnsUnknownWithValue(int msoType) + { + var result = ShapeHelpers.GetShapeTypeName(msoType); + Assert.StartsWith("Unknown(", result); + Assert.Contains(msoType.ToString(System.Globalization.CultureInfo.InvariantCulture), result); + Assert.EndsWith(")", result); + } + + [Fact] + public void GetShapeTypeName_MsoShapeType18_IsNotDefined() + { + // msoShapeType 18 is intentionally skipped in PowerPoint COM API + var result = ShapeHelpers.GetShapeTypeName(18); + Assert.Equal("Unknown(18)", result); + } +} diff --git a/tests/ExcelMcp.Core.Tests/docs/DATA-MODEL-SETUP.md b/tests/PptMcp.Core.Tests/docs/DATA-MODEL-SETUP.md similarity index 93% rename from tests/ExcelMcp.Core.Tests/docs/DATA-MODEL-SETUP.md rename to tests/PptMcp.Core.Tests/docs/DATA-MODEL-SETUP.md index 6bdefdaa..48d07584 100644 --- a/tests/ExcelMcp.Core.Tests/docs/DATA-MODEL-SETUP.md +++ b/tests/PptMcp.Core.Tests/docs/DATA-MODEL-SETUP.md @@ -16,7 +16,7 @@ Data Model tests use a **fixture-as-test pattern** where the fixture initializat **DataModelTestsFixture** (`Helpers/DataModelTestsFixture.cs`) - Creates ONE Data Model file per test CLASS during initialization - Fixture initialization IS the test - validates all creation commands: - - `ExcelBatch.CreateNewWorkbook()` to create new file with session (optimized single start) + - `PptBatch.CreateNewPresentation()` to create new file with session (optimized single start) - `TableCommands.AddToDataModelAsync()` for all tables - `DataModelCommands.CreateRelationshipAsync()` for all relationships - `DataModelCommands.CreateMeasureAsync()` for all measures @@ -28,7 +28,7 @@ Data Model tests use a **fixture-as-test pattern** where the fixture initializat ### Data Model Structure **Created by fixture:** -- 3 Excel Tables: SalesTable (10 rows), CustomersTable (5 rows), ProductsTable (5 rows) +- 3 PowerPoint Tables: SalesTable (10 rows), CustomersTable (5 rows), ProductsTable (5 rows) - 2 Relationships: SalesTable→CustomersTable, SalesTable→ProductsTable - 3 DAX Measures: Total Sales, Average Sale, Total Customers @@ -56,7 +56,7 @@ public partial class DataModelCommandsTests : IClassFixture - net10.0-windows + net9.0-windows true $(NoWarn);CS1591;CA1707;IDE0005 latest @@ -11,8 +11,8 @@ true - Sbroenne.ExcelMcp.Diagnostics.Tests - Sbroenne.ExcelMcp.Diagnostics.Tests + PptMcp.Diagnostics.Tests + PptMcp.Diagnostics.Tests @@ -31,13 +31,12 @@ - - + + - - + diff --git a/tests/PptMcp.McpServer.Tests/Integration/CoreCommandsCoverageTests.cs b/tests/PptMcp.McpServer.Tests/Integration/CoreCommandsCoverageTests.cs new file mode 100644 index 00000000..798837e0 --- /dev/null +++ b/tests/PptMcp.McpServer.Tests/Integration/CoreCommandsCoverageTests.cs @@ -0,0 +1,676 @@ +// Suppress IDE0005 (unnecessary using) – explicit usings kept for clarity in test reflection code +#pragma warning disable IDE0005 +using System.Reflection; +using PptMcp.Core.Commands.Accessibility; +using PptMcp.Core.Commands.Animation; +using PptMcp.Core.Commands.Background; +using PptMcp.Core.Commands.Chart; +using PptMcp.Core.Commands.Comment; +using PptMcp.Core.Commands.CustomShow; +using PptMcp.Core.Commands.Design; +using PptMcp.Core.Commands.DocumentProperty; +using PptMcp.Core.Commands.Export; +using PptMcp.Core.Commands.File; +using PptMcp.Core.Commands.HeaderFooter; +using PptMcp.Core.Commands.Hyperlink; +using PptMcp.Core.Commands.Image; +using PptMcp.Core.Commands.Master; +using PptMcp.Core.Commands.Media; +using PptMcp.Core.Commands.Notes; +using PptMcp.Core.Commands.PageSetup; +using PptMcp.Core.Commands.Placeholder; +using PptMcp.Core.Commands.Proofing; +using PptMcp.Core.Commands.Section; +using PptMcp.Core.Commands.Shape; +using PptMcp.Core.Commands.ShapeAlign; +using PptMcp.Core.Commands.Slide; +using PptMcp.Core.Commands.SlideImport; +using PptMcp.Core.Commands.Slideshow; +using PptMcp.Core.Commands.SlideTable; +using PptMcp.Core.Commands.SmartArt; +using PptMcp.Core.Commands.Tag; +using PptMcp.Core.Commands.Text; +using PptMcp.Core.Commands.Transition; +using PptMcp.Core.Commands.Vba; +using PptMcp.Core.Commands.Window; +#pragma warning restore IDE0005 +using PptMcp.Generated; +using Xunit; + +namespace PptMcp.McpServer.Tests.Integration; + +/// +/// CRITICAL: Automated verification that all Core Commands methods are exposed via generated actions. +/// These tests PREVENT regression by ensuring compile-time and runtime coverage. +/// +public class CoreCommandsCoverageTests +{ + // ── Existing coverage tests ────────────────────────────── + + [Fact] + public void ISlideCommands_AllMethodsHaveEnumValues() + { + var coreMethodCount = GetServiceActionMethodCount(typeof(ISlideCommands)); + var enumValueCount = Enum.GetValues().Length; + Assert.True(enumValueCount >= coreMethodCount, + $"ISlideCommands has {coreMethodCount} [ServiceAction] methods but SlideAction has only {enumValueCount} enum values."); + } + + [Fact] + public void IShapeCommands_AllMethodsHaveEnumValues() + { + var coreMethodCount = GetServiceActionMethodCount(typeof(IShapeCommands)); + var enumValueCount = Enum.GetValues().Length; + Assert.True(enumValueCount >= coreMethodCount, + $"IShapeCommands has {coreMethodCount} [ServiceAction] methods but ShapeAction has only {enumValueCount} enum values."); + } + + [Fact] + public void ITextCommands_AllMethodsHaveEnumValues() + { + var coreMethodCount = GetServiceActionMethodCount(typeof(ITextCommands)); + var enumValueCount = Enum.GetValues().Length; + Assert.True(enumValueCount >= coreMethodCount, + $"ITextCommands has {coreMethodCount} [ServiceAction] methods but TextAction has only {enumValueCount} enum values."); + } + + [Fact] + public void INotesCommands_AllMethodsHaveEnumValues() + { + var coreMethodCount = GetServiceActionMethodCount(typeof(INotesCommands)); + var enumValueCount = Enum.GetValues().Length; + Assert.True(enumValueCount >= coreMethodCount, + $"INotesCommands has {coreMethodCount} [ServiceAction] methods but NotesAction has only {enumValueCount} enum values."); + } + + [Fact] + public void IMasterCommands_AllMethodsHaveEnumValues() + { + var coreMethodCount = GetServiceActionMethodCount(typeof(IMasterCommands)); + var enumValueCount = Enum.GetValues().Length; + Assert.True(enumValueCount >= coreMethodCount, + $"IMasterCommands has {coreMethodCount} [ServiceAction] methods but MasterAction has only {enumValueCount} enum values."); + } + + [Fact] + public void IExportCommands_AllMethodsHaveEnumValues() + { + var coreMethodCount = GetServiceActionMethodCount(typeof(IExportCommands)); + var enumValueCount = Enum.GetValues().Length; + Assert.True(enumValueCount >= coreMethodCount, + $"IExportCommands has {coreMethodCount} [ServiceAction] methods but ExportAction has only {enumValueCount} enum values."); + } + + [Fact] + public void ITransitionCommands_AllMethodsHaveEnumValues() + { + var coreMethodCount = GetServiceActionMethodCount(typeof(ITransitionCommands)); + var enumValueCount = Enum.GetValues().Length; + Assert.True(enumValueCount >= coreMethodCount, + $"ITransitionCommands has {coreMethodCount} [ServiceAction] methods but TransitionAction has only {enumValueCount} enum values."); + } + + [Fact] + public void IImageCommands_AllMethodsHaveEnumValues() + { + var coreMethodCount = GetServiceActionMethodCount(typeof(IImageCommands)); + var enumValueCount = Enum.GetValues().Length; + Assert.True(enumValueCount >= coreMethodCount, + $"IImageCommands has {coreMethodCount} [ServiceAction] methods but ImageAction has only {enumValueCount} enum values."); + } + + // ── NEW: Coverage tests for previously untested command areas ── + + [Fact] + public void IAnimationCommands_AllMethodsHaveEnumValues() + { + var coreMethodCount = GetServiceActionMethodCount(typeof(IAnimationCommands)); + var enumValueCount = Enum.GetValues().Length; + Assert.True(enumValueCount >= coreMethodCount, + $"IAnimationCommands has {coreMethodCount} [ServiceAction] methods but AnimationAction has only {enumValueCount} enum values."); + } + + [Fact] + public void IChartCommands_AllMethodsHaveEnumValues() + { + var coreMethodCount = GetServiceActionMethodCount(typeof(IChartCommands)); + var enumValueCount = Enum.GetValues().Length; + Assert.True(enumValueCount >= coreMethodCount, + $"IChartCommands has {coreMethodCount} [ServiceAction] methods but ChartAction has only {enumValueCount} enum values."); + } + + [Fact] + public void IDesignCommands_AllMethodsHaveEnumValues() + { + var coreMethodCount = GetServiceActionMethodCount(typeof(IDesignCommands)); + var enumValueCount = Enum.GetValues().Length; + Assert.True(enumValueCount >= coreMethodCount, + $"IDesignCommands has {coreMethodCount} [ServiceAction] methods but DesignAction has only {enumValueCount} enum values."); + } + + [Fact] + public void IDocumentPropertyCommands_AllMethodsHaveEnumValues() + { + var coreMethodCount = GetServiceActionMethodCount(typeof(IDocumentPropertyCommands)); + var enumValueCount = Enum.GetValues().Length; + Assert.True(enumValueCount >= coreMethodCount, + $"IDocumentPropertyCommands has {coreMethodCount} [ServiceAction] methods but DocpropertyAction has only {enumValueCount} enum values."); + } + + [Fact] + public void IHyperlinkCommands_AllMethodsHaveEnumValues() + { + var coreMethodCount = GetServiceActionMethodCount(typeof(IHyperlinkCommands)); + var enumValueCount = Enum.GetValues().Length; + Assert.True(enumValueCount >= coreMethodCount, + $"IHyperlinkCommands has {coreMethodCount} [ServiceAction] methods but HyperlinkAction has only {enumValueCount} enum values."); + } + + [Fact] + public void IMediaCommands_AllMethodsHaveEnumValues() + { + var coreMethodCount = GetServiceActionMethodCount(typeof(IMediaCommands)); + var enumValueCount = Enum.GetValues().Length; + Assert.True(enumValueCount >= coreMethodCount, + $"IMediaCommands has {coreMethodCount} [ServiceAction] methods but MediaAction has only {enumValueCount} enum values."); + } + + [Fact] + public void ISectionCommands_AllMethodsHaveEnumValues() + { + var coreMethodCount = GetServiceActionMethodCount(typeof(ISectionCommands)); + var enumValueCount = Enum.GetValues().Length; + Assert.True(enumValueCount >= coreMethodCount, + $"ISectionCommands has {coreMethodCount} [ServiceAction] methods but SectionAction has only {enumValueCount} enum values."); + } + + [Fact] + public void ISlideshowCommands_AllMethodsHaveEnumValues() + { + var coreMethodCount = GetServiceActionMethodCount(typeof(ISlideshowCommands)); + var enumValueCount = Enum.GetValues().Length; + Assert.True(enumValueCount >= coreMethodCount, + $"ISlideshowCommands has {coreMethodCount} [ServiceAction] methods but SlideshowAction has only {enumValueCount} enum values."); + } + + [Fact] + public void ISlideTableCommands_AllMethodsHaveEnumValues() + { + var coreMethodCount = GetServiceActionMethodCount(typeof(ISlideTableCommands)); + var enumValueCount = Enum.GetValues().Length; + Assert.True(enumValueCount >= coreMethodCount, + $"ISlideTableCommands has {coreMethodCount} [ServiceAction] methods but SlidetableAction has only {enumValueCount} enum values."); + } + + [Fact] + public void IVbaCommands_AllMethodsHaveEnumValues() + { + var coreMethodCount = GetServiceActionMethodCount(typeof(IVbaCommands)); + var enumValueCount = Enum.GetValues().Length; + Assert.True(enumValueCount >= coreMethodCount, + $"IVbaCommands has {coreMethodCount} [ServiceAction] methods but VbaAction has only {enumValueCount} enum values."); + } + + [Fact] + public void IWindowCommands_AllMethodsHaveEnumValues() + { + var coreMethodCount = GetServiceActionMethodCount(typeof(IWindowCommands)); + var enumValueCount = Enum.GetValues().Length; + Assert.True(enumValueCount >= coreMethodCount, + $"IWindowCommands has {coreMethodCount} [ServiceAction] methods but WindowAction has only {enumValueCount} enum values."); + } + + [Fact] + public void IFileCommands_AllMethodsHaveEnumValues() + { + var coreMethodCount = GetServiceActionMethodCount(typeof(IFileCommands)); + var enumValueCount = Enum.GetValues().Length; + Assert.True(enumValueCount >= coreMethodCount, + $"IFileCommands has {coreMethodCount} [ServiceAction] methods but FileAction has only {enumValueCount} enum values."); + } + + [Fact] + public void ICommentCommands_AllMethodsHaveEnumValues() + { + var coreMethodCount = GetServiceActionMethodCount(typeof(ICommentCommands)); + var enumValueCount = Enum.GetValues().Length; + Assert.True(enumValueCount >= coreMethodCount, + $"ICommentCommands has {coreMethodCount} [ServiceAction] methods but CommentAction has only {enumValueCount} enum values."); + } + + [Fact] + public void IPlaceholderCommands_AllMethodsHaveEnumValues() + { + var coreMethodCount = GetServiceActionMethodCount(typeof(IPlaceholderCommands)); + var enumValueCount = Enum.GetValues().Length; + Assert.True(enumValueCount >= coreMethodCount, + $"IPlaceholderCommands has {coreMethodCount} [ServiceAction] methods but PlaceholderAction has only {enumValueCount} enum values."); + } + + [Fact] + public void IBackgroundCommands_AllMethodsHaveEnumValues() + { + var coreMethodCount = GetServiceActionMethodCount(typeof(IBackgroundCommands)); + var enumValueCount = Enum.GetValues().Length; + Assert.True(enumValueCount >= coreMethodCount, + $"IBackgroundCommands has {coreMethodCount} [ServiceAction] methods but BackgroundAction has only {enumValueCount} enum values."); + } + + [Fact] + public void IHeaderFooterCommands_AllMethodsHaveEnumValues() + { + var coreMethodCount = GetServiceActionMethodCount(typeof(IHeaderFooterCommands)); + var enumValueCount = Enum.GetValues().Length; + Assert.True(enumValueCount >= coreMethodCount, + $"IHeaderFooterCommands has {coreMethodCount} [ServiceAction] methods but HeaderfooterAction has only {enumValueCount} enum values."); + } + + [Fact] + public void ISmartArtCommands_AllMethodsHaveEnumValues() + { + var coreMethodCount = GetServiceActionMethodCount(typeof(ISmartArtCommands)); + var enumValueCount = Enum.GetValues().Length; + Assert.True(enumValueCount >= coreMethodCount, + $"ISmartArtCommands has {coreMethodCount} [ServiceAction] methods but SmartartAction has only {enumValueCount} enum values."); + } + + [Fact] + public void IShapeAlignCommands_AllMethodsHaveEnumValues() + { + var coreMethodCount = GetServiceActionMethodCount(typeof(IShapeAlignCommands)); + var enumValueCount = Enum.GetValues().Length; + Assert.True(enumValueCount >= coreMethodCount, + $"IShapeAlignCommands has {coreMethodCount} [ServiceAction] methods but ShapealignAction has only {enumValueCount} enum values."); + } + + [Fact] + public void ICustomShowCommands_AllMethodsHaveEnumValues() + { + var coreMethodCount = GetServiceActionMethodCount(typeof(ICustomShowCommands)); + var enumValueCount = Enum.GetValues().Length; + Assert.True(enumValueCount >= coreMethodCount, + $"ICustomShowCommands has {coreMethodCount} [ServiceAction] methods but CustomshowAction has only {enumValueCount} enum values."); + } + + [Fact] + public void IPageSetupCommands_AllMethodsHaveEnumValues() + { + var coreMethodCount = GetServiceActionMethodCount(typeof(IPageSetupCommands)); + var enumValueCount = Enum.GetValues().Length; + Assert.True(enumValueCount >= coreMethodCount, + $"IPageSetupCommands has {coreMethodCount} [ServiceAction] methods but PagesetupAction has only {enumValueCount} enum values."); + } + + [Fact] + public void ISlideImportCommands_AllMethodsHaveEnumValues() + { + var coreMethodCount = GetServiceActionMethodCount(typeof(ISlideImportCommands)); + var enumValueCount = Enum.GetValues().Length; + Assert.True(enumValueCount >= coreMethodCount, + $"ISlideImportCommands has {coreMethodCount} [ServiceAction] methods but SlideimportAction has only {enumValueCount} enum values."); + } + + [Fact] + public void ITagCommands_AllMethodsHaveEnumValues() + { + var coreMethodCount = GetServiceActionMethodCount(typeof(ITagCommands)); + var enumValueCount = Enum.GetValues().Length; + Assert.True(enumValueCount >= coreMethodCount, + $"ITagCommands has {coreMethodCount} [ServiceAction] methods but TagAction has only {enumValueCount} enum values."); + } + + // ── Existing mapping tests ─────────────────────────────── + + /// + /// Verifies all generated action enums have ToActionString mappings via ServiceRegistry. + /// + [Fact] + public void SlideAction_AllEnumValuesHaveMappings() + { + foreach (var action in Enum.GetValues()) + { + var exception = Record.Exception(() => ServiceRegistry.Slide.ToActionString(action)); + Assert.Null(exception); + Assert.NotEmpty(ServiceRegistry.Slide.ToActionString(action)); + } + } + + [Fact] + public void ShapeAction_AllEnumValuesHaveMappings() + { + foreach (var action in Enum.GetValues()) + { + var exception = Record.Exception(() => ServiceRegistry.Shape.ToActionString(action)); + Assert.Null(exception); + Assert.NotEmpty(ServiceRegistry.Shape.ToActionString(action)); + } + } + + [Fact] + public void TextAction_AllEnumValuesHaveMappings() + { + foreach (var action in Enum.GetValues()) + { + var exception = Record.Exception(() => ServiceRegistry.Text.ToActionString(action)); + Assert.Null(exception); + Assert.NotEmpty(ServiceRegistry.Text.ToActionString(action)); + } + } + + // ── NEW: Mapping tests for previously untested action enums ── + + [Fact] + public void AnimationAction_AllEnumValuesHaveMappings() + { + foreach (var action in Enum.GetValues()) + { + var exception = Record.Exception(() => ServiceRegistry.Animation.ToActionString(action)); + Assert.Null(exception); + Assert.NotEmpty(ServiceRegistry.Animation.ToActionString(action)); + } + } + + [Fact] + public void ChartAction_AllEnumValuesHaveMappings() + { + foreach (var action in Enum.GetValues()) + { + var exception = Record.Exception(() => ServiceRegistry.Chart.ToActionString(action)); + Assert.Null(exception); + Assert.NotEmpty(ServiceRegistry.Chart.ToActionString(action)); + } + } + + [Fact] + public void DesignAction_AllEnumValuesHaveMappings() + { + foreach (var action in Enum.GetValues()) + { + var exception = Record.Exception(() => ServiceRegistry.Design.ToActionString(action)); + Assert.Null(exception); + Assert.NotEmpty(ServiceRegistry.Design.ToActionString(action)); + } + } + + [Fact] + public void DocpropertyAction_AllEnumValuesHaveMappings() + { + foreach (var action in Enum.GetValues()) + { + var exception = Record.Exception(() => ServiceRegistry.Docproperty.ToActionString(action)); + Assert.Null(exception); + Assert.NotEmpty(ServiceRegistry.Docproperty.ToActionString(action)); + } + } + + [Fact] + public void HyperlinkAction_AllEnumValuesHaveMappings() + { + foreach (var action in Enum.GetValues()) + { + var exception = Record.Exception(() => ServiceRegistry.Hyperlink.ToActionString(action)); + Assert.Null(exception); + Assert.NotEmpty(ServiceRegistry.Hyperlink.ToActionString(action)); + } + } + + [Fact] + public void MediaAction_AllEnumValuesHaveMappings() + { + foreach (var action in Enum.GetValues()) + { + var exception = Record.Exception(() => ServiceRegistry.Media.ToActionString(action)); + Assert.Null(exception); + Assert.NotEmpty(ServiceRegistry.Media.ToActionString(action)); + } + } + + [Fact] + public void SectionAction_AllEnumValuesHaveMappings() + { + foreach (var action in Enum.GetValues()) + { + var exception = Record.Exception(() => ServiceRegistry.Section.ToActionString(action)); + Assert.Null(exception); + Assert.NotEmpty(ServiceRegistry.Section.ToActionString(action)); + } + } + + [Fact] + public void SlideshowAction_AllEnumValuesHaveMappings() + { + foreach (var action in Enum.GetValues()) + { + var exception = Record.Exception(() => ServiceRegistry.Slideshow.ToActionString(action)); + Assert.Null(exception); + Assert.NotEmpty(ServiceRegistry.Slideshow.ToActionString(action)); + } + } + + [Fact] + public void SlidetableAction_AllEnumValuesHaveMappings() + { + foreach (var action in Enum.GetValues()) + { + var exception = Record.Exception(() => ServiceRegistry.Slidetable.ToActionString(action)); + Assert.Null(exception); + Assert.NotEmpty(ServiceRegistry.Slidetable.ToActionString(action)); + } + } + + [Fact] + public void VbaAction_AllEnumValuesHaveMappings() + { + foreach (var action in Enum.GetValues()) + { + var exception = Record.Exception(() => ServiceRegistry.Vba.ToActionString(action)); + Assert.Null(exception); + Assert.NotEmpty(ServiceRegistry.Vba.ToActionString(action)); + } + } + + [Fact] + public void WindowAction_AllEnumValuesHaveMappings() + { + foreach (var action in Enum.GetValues()) + { + var exception = Record.Exception(() => ServiceRegistry.Window.ToActionString(action)); + Assert.Null(exception); + Assert.NotEmpty(ServiceRegistry.Window.ToActionString(action)); + } + } + + [Fact] + public void NotesAction_AllEnumValuesHaveMappings() + { + foreach (var action in Enum.GetValues()) + { + var exception = Record.Exception(() => ServiceRegistry.Notes.ToActionString(action)); + Assert.Null(exception); + Assert.NotEmpty(ServiceRegistry.Notes.ToActionString(action)); + } + } + + [Fact] + public void MasterAction_AllEnumValuesHaveMappings() + { + foreach (var action in Enum.GetValues()) + { + var exception = Record.Exception(() => ServiceRegistry.Master.ToActionString(action)); + Assert.Null(exception); + Assert.NotEmpty(ServiceRegistry.Master.ToActionString(action)); + } + } + + [Fact] + public void ExportAction_AllEnumValuesHaveMappings() + { + foreach (var action in Enum.GetValues()) + { + var exception = Record.Exception(() => ServiceRegistry.Export.ToActionString(action)); + Assert.Null(exception); + Assert.NotEmpty(ServiceRegistry.Export.ToActionString(action)); + } + } + + [Fact] + public void TransitionAction_AllEnumValuesHaveMappings() + { + foreach (var action in Enum.GetValues()) + { + var exception = Record.Exception(() => ServiceRegistry.Transition.ToActionString(action)); + Assert.Null(exception); + Assert.NotEmpty(ServiceRegistry.Transition.ToActionString(action)); + } + } + + [Fact] + public void ImageAction_AllEnumValuesHaveMappings() + { + foreach (var action in Enum.GetValues()) + { + var exception = Record.Exception(() => ServiceRegistry.Image.ToActionString(action)); + Assert.Null(exception); + Assert.NotEmpty(ServiceRegistry.Image.ToActionString(action)); + } + } + + [Fact] + public void FileAction_AllEnumValuesHaveMappings() + { + foreach (var action in Enum.GetValues()) + { + var exception = Record.Exception(() => ServiceRegistry.File.ToActionString(action)); + Assert.Null(exception); + Assert.NotEmpty(ServiceRegistry.File.ToActionString(action)); + } + } + + [Fact] + public void CommentAction_AllEnumValuesHaveMappings() + { + foreach (var action in Enum.GetValues()) + { + var exception = Record.Exception(() => ServiceRegistry.Comment.ToActionString(action)); + Assert.Null(exception); + Assert.NotEmpty(ServiceRegistry.Comment.ToActionString(action)); + } + } + + [Fact] + public void PlaceholderAction_AllEnumValuesHaveMappings() + { + foreach (var action in Enum.GetValues()) + { + var exception = Record.Exception(() => ServiceRegistry.Placeholder.ToActionString(action)); + Assert.Null(exception); + Assert.NotEmpty(ServiceRegistry.Placeholder.ToActionString(action)); + } + } + + [Fact] + public void BackgroundAction_AllEnumValuesHaveMappings() + { + foreach (var action in Enum.GetValues()) + { + var exception = Record.Exception(() => ServiceRegistry.Background.ToActionString(action)); + Assert.Null(exception); + Assert.NotEmpty(ServiceRegistry.Background.ToActionString(action)); + } + } + + [Fact] + public void HeaderfooterAction_AllEnumValuesHaveMappings() + { + foreach (var action in Enum.GetValues()) + { + var exception = Record.Exception(() => ServiceRegistry.Headerfooter.ToActionString(action)); + Assert.Null(exception); + Assert.NotEmpty(ServiceRegistry.Headerfooter.ToActionString(action)); + } + } + + [Fact] + public void SmartartAction_AllEnumValuesHaveMappings() + { + foreach (var action in Enum.GetValues()) + { + var exception = Record.Exception(() => ServiceRegistry.Smartart.ToActionString(action)); + Assert.Null(exception); + Assert.NotEmpty(ServiceRegistry.Smartart.ToActionString(action)); + } + } + + [Fact] + public void ShapealignAction_AllEnumValuesHaveMappings() + { + foreach (var action in Enum.GetValues()) + { + var exception = Record.Exception(() => ServiceRegistry.Shapealign.ToActionString(action)); + Assert.Null(exception); + Assert.NotEmpty(ServiceRegistry.Shapealign.ToActionString(action)); + } + } + + [Fact] + public void CustomshowAction_AllEnumValuesHaveMappings() + { + foreach (var action in Enum.GetValues()) + { + var exception = Record.Exception(() => ServiceRegistry.Customshow.ToActionString(action)); + Assert.Null(exception); + Assert.NotEmpty(ServiceRegistry.Customshow.ToActionString(action)); + } + } + + [Fact] + public void PagesetupAction_AllEnumValuesHaveMappings() + { + foreach (var action in Enum.GetValues()) + { + var exception = Record.Exception(() => ServiceRegistry.Pagesetup.ToActionString(action)); + Assert.Null(exception); + Assert.NotEmpty(ServiceRegistry.Pagesetup.ToActionString(action)); + } + } + + [Fact] + public void SlideimportAction_AllEnumValuesHaveMappings() + { + foreach (var action in Enum.GetValues()) + { + var exception = Record.Exception(() => ServiceRegistry.Slideimport.ToActionString(action)); + Assert.Null(exception); + Assert.NotEmpty(ServiceRegistry.Slideimport.ToActionString(action)); + } + } + + [Fact] + public void TagAction_AllEnumValuesHaveMappings() + { + foreach (var action in Enum.GetValues()) + { + var exception = Record.Exception(() => ServiceRegistry.Tag.ToActionString(action)); + Assert.Null(exception); + Assert.NotEmpty(ServiceRegistry.Tag.ToActionString(action)); + } + } + + /// + /// Helper: Counts methods with [ServiceAction] attribute in an interface. + /// + private static int GetServiceActionMethodCount(Type interfaceType) + { + return interfaceType + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(m => m.GetCustomAttributes() + .Any(a => a.GetType().Name == "ServiceActionAttribute")) + .Select(m => m.Name) + .Distinct(StringComparer.Ordinal) + .Count(); + } +} + + + + diff --git a/tests/ExcelMcp.McpServer.Tests/Integration/McpServerIntegrationTests.cs b/tests/PptMcp.McpServer.Tests/Integration/McpServerIntegrationTests.cs similarity index 76% rename from tests/ExcelMcp.McpServer.Tests/Integration/McpServerIntegrationTests.cs rename to tests/PptMcp.McpServer.Tests/Integration/McpServerIntegrationTests.cs index cb6e5c89..e6e93775 100644 --- a/tests/ExcelMcp.McpServer.Tests/Integration/McpServerIntegrationTests.cs +++ b/tests/PptMcp.McpServer.Tests/Integration/McpServerIntegrationTests.cs @@ -1,22 +1,20 @@ -// Copyright (c) Sbroenne. All rights reserved. +// Copyright (c) Sbroenne. +// Copyright (c) 2026 Torsten Mahr. All rights reserved. // Licensed under the MIT License. using System.IO.Pipelines; -using Microsoft.ApplicationInsights; -using Microsoft.ApplicationInsights.Extensibility; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; -using Sbroenne.ExcelMcp.McpServer.Telemetry; -using Sbroenne.ExcelMcp.McpServer.Tools; +using PptMcp.McpServer.Tools; using Xunit; using Xunit.Abstractions; // Avoid namespace conflict: McpServer is both a type and namespace using Server = ModelContextProtocol.Server; -namespace Sbroenne.ExcelMcp.McpServer.Tests.Integration; +namespace PptMcp.McpServer.Tests.Integration; /// /// Integration tests that exercise the full MCP protocol using in-memory transport. @@ -45,44 +43,47 @@ public class McpServerIntegrationTests(ITestOutputHelper output) : IAsyncLifetim /// /// Expected tool names from our assembly - the source of truth. - /// After token optimization split (issue #341): - /// - range split into 4 tools (range, range_edit, range_format, range_link) - /// - table split into 2 tools (table, table_column) - /// - pivottable split into 3 tools (pivottable, pivottable_field, pivottable_calc) - /// - datamodel split into 2 tools (datamodel, datamodel_relationship) - /// - chart split into 2 tools (chart, chart_config) - /// - worksheet split into 2 tools (worksheet, worksheet_style) - /// - Added slicer and calculation_mode - /// - Added screenshot - /// - Added window + /// PowerPoint MCP Server tools: + /// - file: Session management (hand-coded PptFileTool) + /// - slide, shape, text, notes, master, export, transition, image: Generated from Core interfaces + /// - slidetable, chart, animation, design, slideshow, vba, window: Generated from Core interfaces + /// - hyperlink, section, docproperty, media: Generated from Core interfaces + /// - comment, placeholder, background, headerfooter, smartart, shapealign: Generated from Core interfaces /// private static readonly HashSet ExpectedToolNames = [ - "calculation_mode", + "accessibility", + "animation", + "background", "chart", - "chart_config", - "conditionalformat", - "connection", - "datamodel", - "datamodel_relationship", + "comment", + "customshow", + "design", + "docproperty", + "export", "file", - "namedrange", - "pivottable", - "pivottable_calc", - "pivottable_field", - "powerquery", - "range", - "range_edit", - "range_format", - "range_link", - "screenshot", - "slicer", - "table", - "table_column", + "headerfooter", + "hyperlink", + "image", + "master", + "media", + "notes", + "pagesetup", + "placeholder", + "proofing", + "section", + "shape", + "shapealign", + "slide", + "slideimport", + "slideshow", + "slidetable", + "smartart", + "tag", + "text", + "transition", "vba", - "window", - "worksheet", - "worksheet_style" + "window" ]; /// @@ -95,30 +96,17 @@ public async Task InitializeAsync() var services = new ServiceCollection(); services.AddLogging(builder => builder.AddDebug().SetMinimumLevel(LogLevel.Debug)); - // Configure telemetry (disabled for tests) - services.AddApplicationInsightsTelemetryWorkerService(options => - { - options.ConnectionString = null; - options.EnableHeartbeat = false; - options.EnableAdaptiveSampling = false; - options.EnableQuickPulseMetricStream = false; - options.EnablePerformanceCounterCollectionModule = false; - options.EnableEventCounterCollectionModule = false; - options.EnableDependencyTrackingTelemetryModule = false; - }); - services.AddSingleton(); - - // Add MCP server with tools (same as Program.cs) using stream transport for testing + // Add MCP server with tools using stream transport for testing services .AddMcpServer(options => { - options.ServerInfo = new() { Name = "ExcelMcp-Test", Version = "1.0.0" }; + options.ServerInfo = new() { Name = "PptMcp-Test", Version = "1.0.0" }; options.ServerInstructions = "Test server for integration tests"; }) .WithStreamServerTransport( _clientToServerPipe.Reader.AsStream(), _serverToClientPipe.Writer.AsStream()) - .WithToolsFromAssembly(typeof(ExcelFileTool).Assembly); + .WithToolsFromAssembly(typeof(PptFileTool).Assembly); _serviceProvider = services.BuildServiceProvider(validateScopes: true); @@ -205,7 +193,7 @@ private async Task DisposeAsyncCore() /// - Tool schema generation /// [Fact] - public async Task ListTools_ReturnsAll25ExpectedTools() + public async Task ListTools_ReturnsAllExpectedTools() { output.WriteLine("=== TOOL DISCOVERY VIA MCP PROTOCOL ===\n"); @@ -275,16 +263,16 @@ public async Task ListTools_AllToolsHaveValidSchema() /// This exercises the complete tool invocation path. /// [Fact] - public async Task CallTool_ExcelFileTest_ReturnsSuccess() + public async Task CallTool_PptFileTest_ReturnsSuccess() { output.WriteLine("=== TOOL INVOCATION VIA MCP PROTOCOL ===\n"); // Arrange - Test action doesn't require an actual file - // Parameter names shortened for token optimization: excelPath -> path + // Parameter names shortened for token optimization: presentationPath -> path var arguments = new Dictionary { ["action"] = "test", - ["path"] = "C:\\fake\\test.xlsx" + ["path"] = "C:\\fake\\test.pptx" }; // Act - Call tool via MCP protocol @@ -325,7 +313,7 @@ public async Task ServerInfo_ReturnsCorrectInformation() // Assert Assert.NotNull(serverInfo); - Assert.Equal("ExcelMcp-Test", serverInfo.Name); + Assert.Equal("PptMcp-Test", serverInfo.Name); Assert.Equal("1.0.0", serverInfo.Version); Assert.Equal("Test server for integration tests", serverInstructions); @@ -337,31 +325,6 @@ public async Task ServerInfo_ReturnsCorrectInformation() await Task.CompletedTask; // Satisfy async requirement } - /// - /// Tests that telemetry services are properly registered in DI. - /// - [Fact] - public void DI_TelemetryServicesRegistered() - { - output.WriteLine("=== TELEMETRY DI REGISTRATION ===\n"); - - Assert.NotNull(_serviceProvider); - - // Act - Verify telemetry services are available - var telemetryClient = _serviceProvider.GetService(); - var telemetryInitializers = _serviceProvider.GetServices().ToList(); - - // Assert - Assert.NotNull(telemetryClient); - Assert.Contains(telemetryInitializers, i => i is ExcelMcpTelemetryInitializer); - - output.WriteLine("✓ TelemetryClient registered"); - output.WriteLine($"✓ Found {telemetryInitializers.Count} telemetry initializers"); - output.WriteLine("✓ ExcelMcpTelemetryInitializer present"); - - output.WriteLine("\n✓ Telemetry services correctly registered in DI"); - } - /// /// Tests that all tools can be discovered and iterated via ListToolsAsync. /// Note: SDK 0.5.0+ replaced EnumerateToolsAsync with ListToolsAsync. diff --git a/tests/ExcelMcp.McpServer.Tests/Integration/Models/ActionEnumCompletenessTests.cs b/tests/PptMcp.McpServer.Tests/Integration/Models/ActionEnumCompletenessTests.cs similarity index 87% rename from tests/ExcelMcp.McpServer.Tests/Integration/Models/ActionEnumCompletenessTests.cs rename to tests/PptMcp.McpServer.Tests/Integration/Models/ActionEnumCompletenessTests.cs index 4ec751bc..0e97e0ba 100644 --- a/tests/ExcelMcp.McpServer.Tests/Integration/Models/ActionEnumCompletenessTests.cs +++ b/tests/PptMcp.McpServer.Tests/Integration/Models/ActionEnumCompletenessTests.cs @@ -2,11 +2,11 @@ #pragma warning disable IDE0005 using System.Reflection; #pragma warning restore IDE0005 -using Sbroenne.ExcelMcp.Core.Models.Actions; +using PptMcp.Generated; using Xunit; using Xunit.Abstractions; -namespace Sbroenne.ExcelMcp.McpServer.Tests.Integration.Models; +namespace PptMcp.McpServer.Tests.Integration.Models; /// /// CRITICAL: Ensures all enum values have ToActionString() mappings. @@ -19,7 +19,7 @@ namespace Sbroenne.ExcelMcp.McpServer.Tests.Integration.Models; [Trait("Speed", "Fast")] [Trait("Layer", "McpServer")] [Trait("Feature", "ActionEnums")] -[Trait("RequiresExcel", "false")] +[Trait("RequiresPowerPoint", "false")] public class ActionEnumCompletenessTests(ITestOutputHelper output) { @@ -34,9 +34,9 @@ public class ActionEnumCompletenessTests(ITestOutputHelper output) public void AllActionEnums_HaveCompleteToActionStringMappings() { // Find all *Action enums in Models namespace - var actionEnums = typeof(ActionExtensions).Assembly + var actionEnums = typeof(ServiceRegistry).Assembly .GetTypes() - .Where(t => t.IsEnum && t.Name.EndsWith("Action", StringComparison.Ordinal) && t.Namespace == "Sbroenne.ExcelMcp.Core.Models.Actions") + .Where(t => t.IsEnum && t.Name.EndsWith("Action", StringComparison.Ordinal) && t.Namespace == "PptMcp.Generated") .ToList(); output.WriteLine($"Found {actionEnums.Count} action enums:"); @@ -51,9 +51,10 @@ public void AllActionEnums_HaveCompleteToActionStringMappings() foreach (var enumType in actionEnums) { - // Find the ToActionString extension method for this enum - var extensionMethod = typeof(ActionExtensions) - .GetMethods(BindingFlags.Public | BindingFlags.Static) + // ToActionString is on nested types of ServiceRegistry (e.g. ServiceRegistry.Slide) + var extensionMethod = typeof(ServiceRegistry) + .GetNestedTypes(BindingFlags.Public | BindingFlags.Static) + .SelectMany(t => t.GetMethods(BindingFlags.Public | BindingFlags.Static)) .FirstOrDefault(m => m.Name == "ToActionString" && m.GetParameters().Length == 1 && @@ -110,17 +111,18 @@ public void AllActionEnums_HaveCompleteToActionStringMappings() [Fact] public void AllActionEnums_NoDuplicateActionStrings() { - var actionEnums = typeof(ActionExtensions).Assembly + var actionEnums = typeof(ServiceRegistry).Assembly .GetTypes() - .Where(t => t.IsEnum && t.Name.EndsWith("Action", StringComparison.Ordinal) && t.Namespace == "Sbroenne.ExcelMcp.Core.Models.Actions") + .Where(t => t.IsEnum && t.Name.EndsWith("Action", StringComparison.Ordinal) && t.Namespace == "PptMcp.Generated") .ToList(); var failures = new List(); foreach (var enumType in actionEnums) { - var extensionMethod = typeof(ActionExtensions) - .GetMethods(BindingFlags.Public | BindingFlags.Static) + var extensionMethod = typeof(ServiceRegistry) + .GetNestedTypes(BindingFlags.Public | BindingFlags.Static) + .SelectMany(t => t.GetMethods(BindingFlags.Public | BindingFlags.Static)) .FirstOrDefault(m => m.Name == "ToActionString" && m.GetParameters().Length == 1 && @@ -181,9 +183,9 @@ public void AllActionEnums_NoDuplicateActionStrings() [Fact] public void AllActionEnums_DocumentedInToolFiles() { - var actionEnums = typeof(ActionExtensions).Assembly + var actionEnums = typeof(ServiceRegistry).Assembly .GetTypes() - .Where(t => t.IsEnum && t.Name.EndsWith("Action", StringComparison.Ordinal) && t.Namespace == "Sbroenne.ExcelMcp.Core.Models.Actions") + .Where(t => t.IsEnum && t.Name.EndsWith("Action", StringComparison.Ordinal) && t.Namespace == "PptMcp.Generated") .ToList(); output.WriteLine($"\nExpected tool files with switch statements:"); diff --git a/tests/ExcelMcp.McpServer.Tests/Integration/Tools/ExcelFileToolOperationTrackingTests.cs b/tests/PptMcp.McpServer.Tests/Integration/Tools/PptFileToolOperationTrackingTests.cs similarity index 91% rename from tests/ExcelMcp.McpServer.Tests/Integration/Tools/ExcelFileToolOperationTrackingTests.cs rename to tests/PptMcp.McpServer.Tests/Integration/Tools/PptFileToolOperationTrackingTests.cs index d1105d03..eb7d3b08 100644 --- a/tests/ExcelMcp.McpServer.Tests/Integration/Tools/ExcelFileToolOperationTrackingTests.cs +++ b/tests/PptMcp.McpServer.Tests/Integration/Tools/PptFileToolOperationTrackingTests.cs @@ -1,4 +1,5 @@ -// Copyright (c) Sbroenne. All rights reserved. +// Copyright (c) Sbroenne. +// Copyright (c) 2026 Torsten Mahr. All rights reserved. // Licensed under the MIT License. using System.IO.Pipelines; @@ -8,10 +9,10 @@ using Xunit; using Xunit.Abstractions; -namespace Sbroenne.ExcelMcp.McpServer.Tests.Integration.Tools; +namespace PptMcp.McpServer.Tests.Integration.Tools; /// -/// Tests for ExcelFileTool operation tracking functionality. +/// Tests for PptFileTool operation tracking functionality. /// Verifies that LIST action returns operation counts and that /// CLOSE is blocked when operations are running. /// @@ -20,8 +21,8 @@ namespace Sbroenne.ExcelMcp.McpServer.Tests.Integration.Tools; [Trait("Speed", "Medium")] [Trait("Layer", "McpServer")] [Trait("Feature", "SessionManager")] -[Trait("RequiresExcel", "true")] -public class ExcelFileToolOperationTrackingTests : IAsyncLifetime, IAsyncDisposable +[Trait("RequiresPowerPoint", "true")] +public class PptFileToolOperationTrackingTests : IAsyncLifetime, IAsyncDisposable { private readonly ITestOutputHelper _output; private readonly string _tempDir; @@ -33,10 +34,10 @@ public class ExcelFileToolOperationTrackingTests : IAsyncLifetime, IAsyncDisposa private McpClient? _client; private Task? _serverTask; - public ExcelFileToolOperationTrackingTests(ITestOutputHelper output) + public PptFileToolOperationTrackingTests(ITestOutputHelper output) { _output = output; - _tempDir = Path.Join(Path.GetTempPath(), $"ExcelFileToolOpTrackingTests_{Guid.NewGuid():N}"); + _tempDir = Path.Join(Path.GetTempPath(), $"PptFileToolOpTrackingTests_{Guid.NewGuid():N}"); Directory.CreateDirectory(_tempDir); _output.WriteLine($"Test directory: {_tempDir}"); } @@ -156,7 +157,7 @@ private async Task CallToolAsync(string toolName, Dictionary { ["action"] = "create", @@ -187,7 +188,7 @@ public async Task List_ReturnsSessionsWithOperationStatus() Assert.Equal(0, activeOps.GetInt32()); Assert.True(session.TryGetProperty("canClose", out var canClose)); Assert.True(canClose.GetBoolean()); - Assert.True(session.TryGetProperty("isExcelVisible", out var isVisible)); + Assert.True(session.TryGetProperty("isPowerPointVisible", out var isVisible)); Assert.False(isVisible.GetBoolean()); } finally @@ -203,10 +204,10 @@ public async Task List_ReturnsSessionsWithOperationStatus() } [Fact] - public async Task List_SessionWithShowExcelTrue_ReturnsIsExcelVisibleTrue() + public async Task List_SessionWithShowPowerPointTrue_ReturnsIsPowerPointVisibleTrue() { // Create a unique file with show=true for this test - var testFile = Path.Join(_tempDir, $"ShowExcelTest_{Guid.NewGuid():N}.xlsx"); + var testFile = Path.Join(_tempDir, $"ShowPowerPointTest_{Guid.NewGuid():N}.pptx"); var openResult = await CallToolAsync("file", new Dictionary { ["action"] = "create", @@ -227,7 +228,7 @@ public async Task List_SessionWithShowExcelTrue_ReturnsIsExcelVisibleTrue() var sessions = listResult.GetProperty("sessions"); var session = sessions[0]; - Assert.True(session.GetProperty("isExcelVisible").GetBoolean()); + Assert.True(session.GetProperty("isPowerPointVisible").GetBoolean()); } finally { @@ -248,7 +249,7 @@ public async Task List_SessionWithShowExcelTrue_ReturnsIsExcelVisibleTrue() public async Task Close_NoOperationsRunning_ClosesSuccessfully() { // Create a unique file and session for this test - var testFile = Path.Join(_tempDir, $"CloseTest_{Guid.NewGuid():N}.xlsx"); + var testFile = Path.Join(_tempDir, $"CloseTest_{Guid.NewGuid():N}.pptx"); var openResult = await CallToolAsync("file", new Dictionary { ["action"] = "create", diff --git a/tests/ExcelMcp.McpServer.Tests/Integration/Tools/ExcelFileToolTests.cs b/tests/PptMcp.McpServer.Tests/Integration/Tools/PptFileToolTests.cs similarity index 80% rename from tests/ExcelMcp.McpServer.Tests/Integration/Tools/ExcelFileToolTests.cs rename to tests/PptMcp.McpServer.Tests/Integration/Tools/PptFileToolTests.cs index 5c9e955c..ccd1ef3d 100644 --- a/tests/ExcelMcp.McpServer.Tests/Integration/Tools/ExcelFileToolTests.cs +++ b/tests/PptMcp.McpServer.Tests/Integration/Tools/PptFileToolTests.cs @@ -1,33 +1,33 @@ -// Copyright (c) Sbroenne. All rights reserved. +// Copyright (c) Sbroenne. +// Copyright (c) 2026 Torsten Mahr. All rights reserved. // Licensed under the MIT License. using System.Text.Json; -using Sbroenne.ExcelMcp.Core.Models.Actions; -using Sbroenne.ExcelMcp.McpServer.Tools; +using PptMcp.McpServer.Tools; using Xunit; using Xunit.Abstractions; -namespace Sbroenne.ExcelMcp.McpServer.Tests.Integration.Tools; +namespace PptMcp.McpServer.Tests.Integration.Tools; /// -/// Tests for ExcelFileTool action methods. +/// Tests for PptFileTool action methods. /// These tests call the tool methods directly without MCP transport. /// [Trait("Category", "Integration")] [Trait("Speed", "Fast")] [Trait("Layer", "McpServer")] [Trait("Feature", "File")] -public class ExcelFileToolTests(ITestOutputHelper output) +public class PptFileToolTests(ITestOutputHelper output) { [Fact] public void Create_ProtectedSystemPath_ReturnsJsonError() { // Arrange - path that reliably fails (Windows directory is protected) - var protectedPath = @"C:\Windows\HelloWorld.xlsx"; + var protectedPath = @"C:\Windows\HelloWorld.pptx"; // Act - var result = ExcelFileTool.ExcelFile( - FileAction.Create, + var result = PptFileTool.PptFile( + PptFileAction.Create, path: protectedPath, session_id: null, save: false, @@ -41,7 +41,7 @@ public void Create_ProtectedSystemPath_ReturnsJsonError() var json = JsonDocument.Parse(result).RootElement; Assert.False(json.GetProperty("success").GetBoolean()); Assert.True(json.TryGetProperty("errorMessage", out var errorMsg)); - // Error message may vary based on Excel version and system locale + // Error message may vary based on PowerPoint version and system locale var msg = errorMsg.GetString(); Assert.True(msg!.Contains("Failed") || msg.Contains("Cannot"), $"Expected failure message, got: {msg}"); Assert.True(json.TryGetProperty("isError", out var isError)); @@ -52,11 +52,11 @@ public void Create_ProtectedSystemPath_ReturnsJsonError() public void Create_InvalidPath_ReturnsJsonError() { // Arrange - use a path that will fail (System32, no permission) - var invalidPath = @"C:\Windows\System32\test.xlsx"; + var invalidPath = @"C:\Windows\System32\test.pptx"; // Act - var result = ExcelFileTool.ExcelFile( - FileAction.Create, + var result = PptFileTool.PptFile( + PptFileAction.Create, path: invalidPath, session_id: null, save: false, @@ -70,7 +70,7 @@ public void Create_InvalidPath_ReturnsJsonError() var json = JsonDocument.Parse(result).RootElement; Assert.False(json.GetProperty("success").GetBoolean()); Assert.True(json.TryGetProperty("errorMessage", out var errorMsg)); - // Error message may vary based on Excel version and system locale + // Error message may vary based on PowerPoint version and system locale var msg = errorMsg.GetString(); Assert.True(msg!.Contains("Failed") || msg.Contains("Cannot"), $"Expected failure message, got: {msg}"); Assert.True(json.TryGetProperty("isError", out var isError)); @@ -81,8 +81,8 @@ public void Create_InvalidPath_ReturnsJsonError() public void Create_NullPath_ReturnsJsonError() { // Act - null path should be caught and returned as JSON error - var result = ExcelFileTool.ExcelFile( - FileAction.Create, + var result = PptFileTool.PptFile( + PptFileAction.Create, path: null, session_id: null, save: false, @@ -105,14 +105,14 @@ public void Create_NullPath_ReturnsJsonError() public void Create_ValidPath_ReturnsSuccessWithSessionId() { // Arrange - use temp directory - var tempPath = Path.Join(Path.GetTempPath(), $"ExcelFileToolTest_{Guid.NewGuid():N}.xlsx"); + var tempPath = Path.Join(Path.GetTempPath(), $"PptFileToolTest_{Guid.NewGuid():N}.pptx"); string? sessionId = null; try { // Act - var result = ExcelFileTool.ExcelFile( - FileAction.Create, + var result = PptFileTool.PptFile( + PptFileAction.Create, path: tempPath, session_id: null, save: false, @@ -135,8 +135,8 @@ public void Create_ValidPath_ReturnsSuccessWithSessionId() // Cleanup - close session first if (!string.IsNullOrEmpty(sessionId)) { - ExcelFileTool.ExcelFile( - FileAction.Close, + PptFileTool.PptFile( + PptFileAction.Close, path: null, session_id: sessionId, save: false, @@ -155,11 +155,11 @@ public void Create_ValidPath_ReturnsSuccessWithSessionId() public void Test_NonExistentFile_ReturnsNotFound() { // Arrange - var fakePath = @"C:\NonExistent\fake.xlsx"; + var fakePath = @"C:\NonExistent\fake.pptx"; // Act - var result = ExcelFileTool.ExcelFile( - FileAction.Test, + var result = PptFileTool.PptFile( + PptFileAction.Test, path: fakePath, session_id: null, save: false, diff --git a/tests/ExcelMcp.McpServer.Tests/ExcelMcp.McpServer.Tests.csproj b/tests/PptMcp.McpServer.Tests/PptMcp.McpServer.Tests.csproj similarity index 64% rename from tests/ExcelMcp.McpServer.Tests/ExcelMcp.McpServer.Tests.csproj rename to tests/PptMcp.McpServer.Tests/PptMcp.McpServer.Tests.csproj index bec46c46..9d70d71e 100644 --- a/tests/ExcelMcp.McpServer.Tests/ExcelMcp.McpServer.Tests.csproj +++ b/tests/PptMcp.McpServer.Tests/PptMcp.McpServer.Tests.csproj @@ -1,7 +1,7 @@ - net10.0-windows + net9.0-windows true $(NoWarn);CS1591;CA1707 latest @@ -11,13 +11,12 @@ true - Sbroenne.ExcelMcp.McpServer.Tests - Sbroenne.ExcelMcp.McpServer.Tests + PptMcp.McpServer.Tests + PptMcp.McpServer.Tests - all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -30,9 +29,9 @@ - - - + + + @@ -46,19 +45,19 @@ - $(MSBuildThisFileDirectory)..\..\src\ExcelMcp.CLI\bin\$(Configuration)\net10.0-windows\ + $(MSBuildThisFileDirectory)..\..\src\PptMcp.CLI\bin\$(Configuration)\net9.0-windows\ - - - - + + + + + Condition="Exists('$(CliOutputDir)pptcli.exe')" /> diff --git a/tests/ExcelMcp.McpServer.Tests/ProgramTransportTestCollection.cs b/tests/PptMcp.McpServer.Tests/ProgramTransportTestCollection.cs similarity index 83% rename from tests/ExcelMcp.McpServer.Tests/ProgramTransportTestCollection.cs rename to tests/PptMcp.McpServer.Tests/ProgramTransportTestCollection.cs index 55185751..5ca947f9 100644 --- a/tests/ExcelMcp.McpServer.Tests/ProgramTransportTestCollection.cs +++ b/tests/PptMcp.McpServer.Tests/ProgramTransportTestCollection.cs @@ -1,9 +1,10 @@ -// Copyright (c) Sbroenne. All rights reserved. +// Copyright (c) Sbroenne. +// Copyright (c) 2026 Torsten Mahr. All rights reserved. // Licensed under the MIT License. using Xunit; -namespace Sbroenne.ExcelMcp.McpServer.Tests; +namespace PptMcp.McpServer.Tests; /// /// Collection definition for tests that use Program.ConfigureTestTransport(). @@ -12,7 +13,7 @@ namespace Sbroenne.ExcelMcp.McpServer.Tests; /// /// Tests in this collection: /// - McpServerSmokeTests -/// - ExcelFileToolOperationTrackingTests +/// - PptFileToolOperationTrackingTests /// /// Both use Program.ConfigureTestTransport() which sets static pipe fields. /// Running them in parallel causes "writer already completed" errors. diff --git a/tests/ExcelMcp.McpServer.Tests/Unit/ConfigurationReloadTests.cs b/tests/PptMcp.McpServer.Tests/Unit/ConfigurationReloadTests.cs similarity index 96% rename from tests/ExcelMcp.McpServer.Tests/Unit/ConfigurationReloadTests.cs rename to tests/PptMcp.McpServer.Tests/Unit/ConfigurationReloadTests.cs index da021607..42f256f8 100644 --- a/tests/ExcelMcp.McpServer.Tests/Unit/ConfigurationReloadTests.cs +++ b/tests/PptMcp.McpServer.Tests/Unit/ConfigurationReloadTests.cs @@ -1,17 +1,18 @@ -// Copyright (c) Sbroenne. All rights reserved. +// Copyright (c) Sbroenne. +// Copyright (c) 2026 Torsten Mahr. All rights reserved. // Licensed under the MIT License. using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Xunit; -namespace Sbroenne.ExcelMcp.McpServer.Tests.Unit; +namespace PptMcp.McpServer.Tests.Unit; /// /// Regression tests for MCP Server configuration. /// /// Bug 8 (Feb 2026): Host.CreateApplicationBuilder() enables reloadOnChange:true by default, -/// creating a FileSystemWatcher for appsettings.json. Under file I/O storms from Excel +/// creating a FileSystemWatcher for appsettings.json. Under file I/O storms from PowerPoint /// (temp files, lock files), this watcher fires ParseEventBufferAndNotifyForEach in a tight /// loop on the threadpool, consuming ~85% CPU. /// diff --git a/tests/ExcelMcp.McpServer.Tests/Unit/McpServerVersionCheckerTests.cs b/tests/PptMcp.McpServer.Tests/Unit/McpServerVersionCheckerTests.cs similarity index 96% rename from tests/ExcelMcp.McpServer.Tests/Unit/McpServerVersionCheckerTests.cs rename to tests/PptMcp.McpServer.Tests/Unit/McpServerVersionCheckerTests.cs index 69587585..26545278 100644 --- a/tests/ExcelMcp.McpServer.Tests/Unit/McpServerVersionCheckerTests.cs +++ b/tests/PptMcp.McpServer.Tests/Unit/McpServerVersionCheckerTests.cs @@ -1,6 +1,6 @@ using Xunit; -namespace Sbroenne.ExcelMcp.McpServer.Tests.Unit; +namespace PptMcp.McpServer.Tests.Unit; [Trait("Layer", "McpServer")] [Trait("Category", "Unit")] diff --git a/tests/ExcelMcp.McpServer.Tests/xunit.runner.json b/tests/PptMcp.McpServer.Tests/xunit.runner.json similarity index 100% rename from tests/ExcelMcp.McpServer.Tests/xunit.runner.json rename to tests/PptMcp.McpServer.Tests/xunit.runner.json diff --git a/tests/ExcelMcp.SkillGeneration.Tests/ExcelMcp.SkillGeneration.Tests.csproj b/tests/PptMcp.SkillGeneration.Tests/PptMcp.SkillGeneration.Tests.csproj similarity index 85% rename from tests/ExcelMcp.SkillGeneration.Tests/ExcelMcp.SkillGeneration.Tests.csproj rename to tests/PptMcp.SkillGeneration.Tests/PptMcp.SkillGeneration.Tests.csproj index 15590f35..56bf7fea 100644 --- a/tests/ExcelMcp.SkillGeneration.Tests/ExcelMcp.SkillGeneration.Tests.csproj +++ b/tests/PptMcp.SkillGeneration.Tests/PptMcp.SkillGeneration.Tests.csproj @@ -1,7 +1,7 @@ - net10.0 + net9.0 $(NoWarn);CS1591;CA1707;IDE0005 latest enable @@ -10,8 +10,8 @@ true - Sbroenne.ExcelMcp.SkillGeneration.Tests - Sbroenne.ExcelMcp.SkillGeneration.Tests + PptMcp.SkillGeneration.Tests + PptMcp.SkillGeneration.Tests diff --git a/tests/ExcelMcp.SkillGeneration.Tests/SkillMdQualityTests.cs b/tests/PptMcp.SkillGeneration.Tests/SkillMdQualityTests.cs similarity index 86% rename from tests/ExcelMcp.SkillGeneration.Tests/SkillMdQualityTests.cs rename to tests/PptMcp.SkillGeneration.Tests/SkillMdQualityTests.cs index 3b8d2e77..a7e69972 100644 --- a/tests/ExcelMcp.SkillGeneration.Tests/SkillMdQualityTests.cs +++ b/tests/PptMcp.SkillGeneration.Tests/SkillMdQualityTests.cs @@ -1,7 +1,7 @@ using System.Text.RegularExpressions; using Xunit; -namespace Sbroenne.ExcelMcp.SkillGeneration.Tests; +namespace PptMcp.SkillGeneration.Tests; /// /// Tests to validate the quality of generated SKILL.md files. @@ -18,7 +18,7 @@ public class SkillMdQualityTests [Trait("Feature", "SkillGeneration")] public void CliSkill_Exists() { - var skillPath = Path.Combine(SkillsFolder, "excel-cli", "SKILL.md"); + var skillPath = Path.Combine(SkillsFolder, "ppt-cli", "SKILL.md"); Assert.True(File.Exists(skillPath), $"CLI SKILL.md should exist at {skillPath}"); } @@ -27,7 +27,7 @@ public void CliSkill_Exists() [Trait("Feature", "SkillGeneration")] public void McpSkill_Exists() { - var skillPath = Path.Combine(SkillsFolder, "excel-mcp", "SKILL.md"); + var skillPath = Path.Combine(SkillsFolder, "ppt-mcp", "SKILL.md"); Assert.True(File.Exists(skillPath), $"MCP SKILL.md should exist at {skillPath}"); } @@ -36,7 +36,7 @@ public void McpSkill_Exists() [Trait("Feature", "SkillGeneration")] public void CliSkill_HasNoEmptyParameterDescriptions() { - var skillPath = Path.Combine(SkillsFolder, "excel-cli", "SKILL.md"); + var skillPath = Path.Combine(SkillsFolder, "ppt-cli", "SKILL.md"); AssertNoEmptyDescriptions(skillPath, "CLI"); } @@ -55,7 +55,7 @@ public void McpSkill_HasNoEmptyParameterDescriptions() [Trait("Feature", "SkillGeneration")] public void CliSkill_HasCommands() { - var skillPath = Path.Combine(SkillsFolder, "excel-cli", "SKILL.md"); + var skillPath = Path.Combine(SkillsFolder, "ppt-cli", "SKILL.md"); var content = File.ReadAllText(skillPath); var commandMatches = Regex.Matches(content, @"^### \w+", RegexOptions.Multiline); Assert.True(commandMatches.Count > 0, "CLI SKILL.md should have command headings"); @@ -70,7 +70,7 @@ public void McpSkill_HasTools() // MCP SKILL.md contains curated guidance, not auto-generated tool docs // Tools are discovered via MCP schema at runtime // Verify it has the expected curated content - var skillPath = Path.Combine(SkillsFolder, "excel-mcp", "SKILL.md"); + var skillPath = Path.Combine(SkillsFolder, "ppt-mcp", "SKILL.md"); var content = File.ReadAllText(skillPath); Assert.Contains("file", content); Assert.Contains("range", content); @@ -82,7 +82,7 @@ public void McpSkill_HasTools() [Trait("Feature", "SkillGeneration")] public void CliSkill_HasParameterTables() { - var skillPath = Path.Combine(SkillsFolder, "excel-cli", "SKILL.md"); + var skillPath = Path.Combine(SkillsFolder, "ppt-cli", "SKILL.md"); var content = File.ReadAllText(skillPath); Assert.Contains("| Parameter | Description |", content); } @@ -93,7 +93,7 @@ public void CliSkill_HasParameterTables() public void McpSkill_HasParameterTables() { // MCP SKILL.md has markdown tables for reference, not parameter tables - var skillPath = Path.Combine(SkillsFolder, "excel-mcp", "SKILL.md"); + var skillPath = Path.Combine(SkillsFolder, "ppt-mcp", "SKILL.md"); var content = File.ReadAllText(skillPath); Assert.Contains("| Task | Tool |", content); } @@ -103,7 +103,7 @@ public void McpSkill_HasParameterTables() [Trait("Feature", "SkillGeneration")] public void CliSkill_HasActionsList() { - var skillPath = Path.Combine(SkillsFolder, "excel-cli", "SKILL.md"); + var skillPath = Path.Combine(SkillsFolder, "ppt-cli", "SKILL.md"); var content = File.ReadAllText(skillPath); Assert.Contains("**Actions:**", content); } @@ -114,7 +114,7 @@ public void CliSkill_HasActionsList() public void McpSkill_HasActionsList() { // MCP SKILL.md has curated action examples, not **Actions:** section - var skillPath = Path.Combine(SkillsFolder, "excel-mcp", "SKILL.md"); + var skillPath = Path.Combine(SkillsFolder, "ppt-mcp", "SKILL.md"); var content = File.ReadAllText(skillPath); Assert.Contains("action:", content); } diff --git a/tests/README.md b/tests/README.md index dc4ea757..1a44c3d0 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,6 +1,6 @@ -# ExcelMcp Tests +# PptMcp Tests -> **⚠️ No Traditional Unit Tests**: ExcelMcp has no unit tests. Integration tests ARE our unit tests because Excel COM cannot be meaningfully mocked. See [`docs/ADR-001-NO-UNIT-TESTS.md`](../docs/ADR-001-NO-UNIT-TESTS.md) for full architectural rationale. +> **⚠️ No Traditional Unit Tests**: PptMcp has no unit tests. Integration tests ARE our unit tests because PowerPoint COM cannot be meaningfully mocked. See [`docs/ADR-001-NO-UNIT-TESTS.md`](../docs/ADR-001-NO-UNIT-TESTS.md) for full architectural rationale. ## Quick Start @@ -29,11 +29,11 @@ dotnet test --filter "(Feature=VBA|Feature=VBATrust)&RunType!=OnDemand" ``` tests/ -├── ExcelMcp.Core.Tests/ # Core business logic (Integration) -├── ExcelMcp.Diagnostics.Tests/ # Excel COM behavior research (OnDemand, Manual) -├── ExcelMcp.McpServer.Tests/ # MCP protocol layer (Integration) -├── ExcelMcp.CLI.Tests/ # CLI wrapper (Integration) -└── ExcelMcp.ComInterop.Tests/ # COM utilities (OnDemand) +├── PptMcp.Core.Tests/ # Core business logic (Integration) +├── PptMcp.Diagnostics.Tests/ # PowerPoint COM behavior research (OnDemand, Manual) +├── PptMcp.McpServer.Tests/ # MCP protocol layer (Integration) +├── PptMcp.CLI.Tests/ # CLI wrapper (Integration) +└── PptMcp.ComInterop.Tests/ # COM utilities (OnDemand) llm-tests/ # LLM tool behavior validation (Manual) ``` @@ -42,19 +42,19 @@ llm-tests/ # LLM tool behavior validation (Manual) | Category | Speed | Requirements | Run By Default | |----------|-------|--------------|----------------| -| **Integration** | Medium (10-20 min) | Excel + Windows | ✅ Yes (local) | -| **OnDemand** | Slow (3-5 min) | Excel + Windows | ❌ No (explicit only) | -| **Diagnostics** | Slow (varies) | Excel + Windows | ❌ No (manual, excluded from CI) | -| **LLM Tests** | Slow (varies) | Excel + Azure OpenAI | ❌ No (manual only) | +| **Integration** | Medium (10-20 min) | PowerPoint + Windows | ✅ Yes (local) | +| **OnDemand** | Slow (3-5 min) | PowerPoint + Windows | ❌ No (explicit only) | +| **Diagnostics** | Slow (varies) | PowerPoint + Windows | ❌ No (manual, excluded from CI) | +| **LLM Tests** | Slow (varies) | PowerPoint + Azure OpenAI | ❌ No (manual only) | ## Diagnostics Tests -Diagnostics tests are research/exploratory tests in `ExcelMcp.Diagnostics.Tests` that document the actual behavior of Excel's COM APIs without our abstraction layer. These tests are **excluded from CI** to keep automation focused on core functionality. +Diagnostics tests are research/exploratory tests in `PptMcp.Diagnostics.Tests` that document the actual behavior of PowerPoint's COM APIs without our abstraction layer. These tests are **excluded from CI** to keep automation focused on core functionality. **Purpose:** -- Understand Excel COM API behavior for Power Query, Data Model, PivotTables, etc. +- Understand PowerPoint COM API behavior for Power Query, Data Model, PivotTables, etc. - Document findings and edge cases for future implementation decisions -- Test alternative approaches to complex Excel operations +- Test alternative approaches to complex PowerPoint operations **Trait markers:** - `Layer=Diagnostics` @@ -63,10 +63,10 @@ Diagnostics tests are research/exploratory tests in `ExcelMcp.Diagnostics.Tests` **Run diagnostics tests locally:** ```powershell # All diagnostics tests -dotnet test tests/ExcelMcp.Diagnostics.Tests/ --filter "RunType=OnDemand&Layer=Diagnostics" +dotnet test tests/PptMcp.Diagnostics.Tests/ --filter "RunType=OnDemand&Layer=Diagnostics" # Specific diagnostic tests -dotnet test tests/ExcelMcp.Diagnostics.Tests/ --filter "Feature=PowerQuery&RunType=OnDemand" +dotnet test tests/PptMcp.Diagnostics.Tests/ --filter "Feature=PowerQuery&RunType=OnDemand" ``` **CI Behavior:** @@ -98,7 +98,7 @@ dotnet test --filter "Feature=Connections&RunType!=OnDemand" ## LLM Tests -The `llm-tests/` project validates that LLMs correctly use Excel MCP Server and CLI tools using [pytest-aitest](https://github.com/sbroenne/pytest-aitest). +The `llm-tests/` project validates that LLMs correctly use PowerPoint MCP Server and CLI tools using [pytest-aitest](https://github.com/trsdn/pytest-aitest). ### When to Run LLM Tests @@ -117,7 +117,7 @@ uv run pytest -m aitest -v ### Prerequisites - `AZURE_OPENAI_ENDPOINT` environment variable -- Windows desktop with Excel installed +- Windows desktop with PowerPoint installed - pytest-aitest dependency (local path via uv) **See [LLM Tests README](../llm-tests/README.md) for complete documentation.** @@ -129,7 +129,7 @@ uv run pytest -m aitest -v VBA tests are excluded from normal test runs because: 1. **Stable codebase** - VBA features are mature with minimal changes 2. **Performance** - Excluding VBA tests makes integration tests ~25% faster (10-15 min vs 15-20 min) -3. **Special requirements** - VBA tests require VBA trust enabled in Excel settings +3. **Special requirements** - VBA tests require VBA trust enabled in PowerPoint settings 4. **Opt-in model** - Explicit testing when VBA code changes, rather than every commit ### When to Run VBA Tests @@ -155,26 +155,26 @@ dotnet test --filter "Category=Integration&RunType!=OnDemand" All VBA tests are tagged with `[Trait("Feature", "VBA")]` or `[Trait("Feature", "VBATrust")]`: ``` -tests/ExcelMcp.Core.Tests/Integration/Commands/Script/ +tests/PptMcp.Core.Tests/Integration/Commands/Script/ - ScriptCommandsTests.cs - ScriptCommandsTests.Lifecycle.cs - VbaTrustDetectionTests.ScriptCommands.cs - VbaTrustDetectionTests.cs -tests/ExcelMcp.CLI.Tests/Integration/Commands/ +tests/PptMcp.CLI.Tests/Integration/Commands/ - ScriptAndSetupCommandsTests.cs ``` ### VBA Trust Setup -VBA tests require VBA trust enabled in Excel: +VBA tests require VBA trust enabled in PowerPoint: ```powershell # Enable VBA trust (required for VBA tests) -Set-ItemProperty -Path "HKCU:\Software\Microsoft\Office\16.0\Excel\Security" -Name "AccessVBOM" -Value 1 +Set-ItemProperty -Path "HKCU:\Software\Microsoft\Office\16.0\PowerPoint\Security" -Name "AccessVBOM" -Value 1 # Verify setting -Get-ItemProperty -Path "HKCU:\Software\Microsoft\Office\16.0\Excel\Security" -Name "AccessVBOM" +Get-ItemProperty -Path "HKCU:\Software\Microsoft\Office\16.0\PowerPoint\Security" -Name "AccessVBOM" ``` **Security Note:** Only enable VBA trust in development environments. Production systems should keep this disabled. @@ -183,12 +183,12 @@ Get-ItemProperty -Path "HKCU:\Software\Microsoft\Office\16.0\Excel\Security" -Na - ✅ **File Isolation** - Each test creates unique file (no sharing) - ✅ **Binary Assertions** - Pass OR fail, never "accept both" -- ✅ **Verify Excel State** - Always verify actual Excel state after operations +- ✅ **Verify PowerPoint State** - Always verify actual PowerPoint state after operations - ❌ **No SaveAsync** - Unless testing persistence (see [Rule 14](../.github/instructions/critical-rules.instructions.md#rule-14-no-saveasync-unless-testing-persistence)) ## Getting Help - **Test failures**: Check test output for detailed error messages -- **Excel issues**: Ensure Excel 2016+ installed and activated +- **PowerPoint issues**: Ensure PowerPoint 2016+ installed and activated - **Session/batch issues**: Run OnDemand tests to verify cleanup - **Writing tests**: See [Testing Strategy](../.github/instructions/testing-strategy.instructions.md) diff --git a/vscode-extension/.github/copilot-instructions.md b/vscode-extension/.github/copilot-instructions.md index 0d31654e..7098c3ce 100644 --- a/vscode-extension/.github/copilot-instructions.md +++ b/vscode-extension/.github/copilot-instructions.md @@ -1,25 +1,25 @@ -# Excel MCP Server - Quick Reference +# PowerPoint MCP Server - Quick Reference -> **When user asks about Excel files, spreadsheets, workbooks, or data in .xlsx/.xlsm files - USE the Excel MCP tools.** +> **When user asks about PowerPoint files, presentations, or slides in .pptx/.pptm files - USE the PowerPoint MCP tools.** -## When to Use Excel MCP +## When to Use PowerPoint MCP USE these tools when user wants to: -- Read/write Excel data, formulas, or formatting -- Create PivotTables, charts, or tables +- Read/write PowerPoint data, shapes, or formatting +- Create slides, charts, or tables - Import data via Power Query - Run VBA macros -- Any .xlsx or .xlsm file operations +- Any .pptx or .pptm file operations -DO NOT use for: CSV files (use standard file tools), Google Sheets, or non-Excel formats. +DO NOT use for: CSV files (use standard file tools), Google Slides, or non-PowerPoint formats. --- ## Prerequisites -- **Windows OS** - Excel COM automation requires Windows -- **Microsoft Excel 2016+** - Must be installed -- **File CLOSED in Excel** - COM requires exclusive access +- **Windows OS** - PowerPoint COM automation requires Windows +- **Microsoft PowerPoint 2016+** - Must be installed +- **File CLOSED in PowerPoint** - COM requires exclusive access --- @@ -29,10 +29,10 @@ DO NOT use for: CSV files (use standard file tools), Google Sheets, or non-Excel |------|-----|-----| | Import external data (CSV, SQL, APIs) | `powerquery` | `table` | | DAX measures / calculated fields | `datamodel` | `range` | -| Worksheet formulas (SUM, VLOOKUP) | `range` | `datamodel` | +| Slide formulas (SUM, VLOOKUP) | `range` | `datamodel` | | Structured data with filtering | `table` | `range` | | Interactive summarization | `pivottable` | `table` | -| VBA automation | `vba` | Requires .xlsm | +| VBA automation | `vba` | Requires .pptm | **Data Model prerequisite**: Before using `datamodel`, data must be loaded with `loadDestination: 'data-model'` or `'both'` via `powerquery`. @@ -43,7 +43,7 @@ DO NOT use for: CSV files (use standard file tools), Google Sheets, or non-Excel ### Import Data -> Analyze -> Visualize ``` 1. file(action: 'open') -2. powerquery(action: 'create', loadDestination: 'worksheet') +2. powerquery(action: 'create', loadDestination: 'slide') 3. pivottable(action: 'create-from-table') 4. chart(action: 'create-from-pivottable') 5. file(action: 'close', save: true) @@ -72,10 +72,10 @@ range(action: 'set-formulas', formulas: [['=A1', '=B1']]) # Multiple formulas a | Mistake | Fix | |---------|-----| | Using `table` to import CSV | Use `powerquery` (handles encoding, transforms) | -| Using `range` for DAX | Use `datamodel` (DAX != worksheet formulas) | +| Using `range` for DAX | Use `datamodel` (DAX != slide formulas) | | Multiple single-item calls | Use bulk actions when available | | Closing session between operations | Keep session open until workflow complete | -| Working on file open in Excel | Ask user to close file first | +| Working on file open in PowerPoint | Ask user to close file first | --- @@ -85,9 +85,9 @@ range(action: 'set-formulas', formulas: [['=A1', '=B1']]) # Multiple formulas a file(action: 'open') -> [all operations with sessionId] -> file(action: 'close') ``` -- **DEFAULT: `showExcel: false`** - Use hidden mode for faster background automation -- Only use `showExcel: true` if user explicitly requests to watch changes -- If `showExcel: true` was used, **ask before closing** (user may want to inspect) +- **DEFAULT: `showPowerPoint: false`** - Use hidden mode for faster background automation +- Only use `showPowerPoint: true` if user explicitly requests to watch changes +- If `showPowerPoint: true` was used, **ask before closing** (user may want to inspect) - Use `file(action: 'list')` to check session state if uncertain --- diff --git a/vscode-extension/.github/instructions/extension-development.instructions.md b/vscode-extension/.github/instructions/extension-development.instructions.md index be3aa4a7..49d41018 100644 --- a/vscode-extension/.github/instructions/extension-development.instructions.md +++ b/vscode-extension/.github/instructions/extension-development.instructions.md @@ -4,11 +4,11 @@ applyTo: "vscode-extension/**" # VS Code Extension Development Instructions -> **Instructions for developing the ExcelMcp VS Code Extension** +> **Instructions for developing the PptMcp VS Code Extension** ## Extension Overview -The ExcelMcp VS Code Extension provides one-click installation of the ExcelMcp MCP server for Visual Studio Code, enabling AI assistants like GitHub Copilot to automate Microsoft Excel. +The PptMcp VS Code Extension provides one-click installation of the PptMcp MCP server for Visual Studio Code, enabling AI assistants like GitHub Copilot to automate Microsoft PowerPoint. **Key Files:** - `package.json` - Extension manifest (metadata, dependencies, version) @@ -162,9 +162,9 @@ npm version major # 1.0.0 → 2.0.0 ### Correct Command Syntax -**The extension uses**: `dotnet tool run mcp-excel` +**The extension uses**: `dotnet tool run mcp-ppt` -**NOT**: `dnx Sbroenne.ExcelMcp.McpServer --yes` (this is incorrect) +**NOT**: `dnx PptMcp.McpServer --yes` (this is incorrect) ### Where Commands Are Referenced @@ -184,7 +184,7 @@ Before committing, search for outdated command references: grep -r "dnx" vscode-extension/ # Should only find references in documentation explaining the NuGet approach -# Actual command should be: dotnet tool run mcp-excel +# Actual command should be: dotnet tool run mcp-ppt ``` --- @@ -258,7 +258,7 @@ npx @vscode/vsce publish - Workflow updates it automatically from tag 2. **Don't use dnx commands in documentation** - - Extension uses `dotnet tool run mcp-excel` + - Extension uses `dotnet tool run mcp-ppt` 3. **Don't forget to update CHANGELOG.md** - Marketplace shows changelog - keep it current @@ -269,7 +269,7 @@ npx @vscode/vsce publish ### ✅ Do This 1. **Keep CHANGELOG.md updated** as you develop -2. **Use correct command syntax** (`dotnet tool run mcp-excel`) +2. **Use correct command syntax** (`dotnet tool run mcp-ppt`) 3. **Let workflow manage versions** via git tags 4. **Test locally** before pushing tags 5. **Update README.md** when features change @@ -280,7 +280,7 @@ npx @vscode/vsce publish 1. **CHANGELOG.md is always ready** - Top entry is for next release 2. **Workflow manages versions** - Don't manually edit package.json -3. **Correct command syntax** - `dotnet tool run mcp-excel` (not dnx) +3. **Correct command syntax** - `dotnet tool run mcp-ppt` (not dnx) 4. **Marketplace accuracy** - README.md and CHANGELOG.md must be current 5. **Test before release** - Use F5 or local VSIX install diff --git a/vscode-extension/DEVELOPMENT.md b/vscode-extension/DEVELOPMENT.md index cd8411bf..6b0b83bc 100644 --- a/vscode-extension/DEVELOPMENT.md +++ b/vscode-extension/DEVELOPMENT.md @@ -19,13 +19,13 @@ vscode-extension/ ├── icon.png # 128x128 extension icon ├── icon.svg # SVG source ├── skills/ # Agent skills (copied during build) -│ ├── excel-mcp/ # MCP server skill +│ ├── ppt-mcp/ # MCP server skill │ │ └── SKILL.md -│ ├── excel-cli/ # CLI skill +│ ├── ppt-cli/ # CLI skill │ │ └── SKILL.md │ └── shared/ # Shared reference docs │ └── *.md -└── excelmcp-1.0.0.vsix # Packaged extension +└── PptMcp-1.0.0.vsix # Packaged extension ``` ## Key Implementation Details @@ -35,12 +35,12 @@ vscode-extension/ The extension uses VS Code's `mcpServerDefinitionProvider` contribution point: ```typescript -vscode.lm.registerMcpServerDefinitionProvider('excelmcp', { +vscode.lm.registerMcpServerDefinitionProvider('PptMcp', { provideMcpServerDefinitions: async () => { - const serverPath = path.join(context.extensionPath, 'bin', 'Sbroenne.ExcelMcp.McpServer.exe'); + const serverPath = path.join(context.extensionPath, 'bin', 'PptMcp.McpServer.exe'); return [ new vscode.McpStdioServerDefinition( - 'Excel MCP Server', + 'PowerPoint MCP Server', serverPath, [], {} // Optional environment variables @@ -56,8 +56,8 @@ The extension uses VS Code's `chatSkills` contribution point in `package.json` t ```json "chatSkills": [ - { "name": "excel-mcp", "path": "./skills/excel-mcp/SKILL.md" }, - { "name": "excel-cli", "path": "./skills/excel-cli/SKILL.md" } + { "name": "ppt-mcp", "path": "./skills/ppt-mcp/SKILL.md" }, + { "name": "ppt-cli", "path": "./skills/ppt-cli/SKILL.md" } ] ``` @@ -95,18 +95,18 @@ The extension includes self-contained MCP server and CLI executables. To update ```powershell # Build MCP server as self-contained single-file exe -cd d:\source\mcp-server-excel -dotnet publish src/ExcelMcp.McpServer/ExcelMcp.McpServer.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishTrimmed=false -p:PublishReadyToRun=false -p:NuGetAudit=false -o vscode-extension/bin +cd d:\source\mcp-server-ppt +dotnet publish src/PptMcp.McpServer/PptMcp.McpServer.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishTrimmed=false -p:PublishReadyToRun=false -p:NuGetAudit=false -o vscode-extension/bin # Build CLI as self-contained single-file exe -dotnet publish src/ExcelMcp.CLI/ExcelMcp.CLI.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishTrimmed=false -p:PublishReadyToRun=false -p:NuGetAudit=false -o vscode-extension/bin +dotnet publish src/PptMcp.CLI/PptMcp.CLI.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishTrimmed=false -p:PublishReadyToRun=false -p:NuGetAudit=false -o vscode-extension/bin # Or use the npm script which builds both npm run build:all # Verify the executables work -vscode-extension/bin/Sbroenne.ExcelMcp.McpServer.exe --version -vscode-extension/bin/excelcli.exe --version +vscode-extension/bin/PptMcp.McpServer.exe --version +vscode-extension/bin/pptcli.exe --version ``` This creates self-contained executables with the .NET runtime and all dependencies included. No .NET SDK or runtime installation needed on end-user machines. @@ -122,8 +122,8 @@ The extension uses bundled self-contained executables. For development testing: npm run build:all # Verify bundled executables work -vscode-extension/bin/Sbroenne.ExcelMcp.McpServer.exe --version -vscode-extension/bin/excelcli.exe --version +vscode-extension/bin/PptMcp.McpServer.exe --version +vscode-extension/bin/pptcli.exe --version ``` **Why this approach**: The extension bundles self-contained MCP server and CLI executables. No .NET runtime or SDK needed on the target machine. @@ -138,17 +138,17 @@ vscode-extension/bin/excelcli.exe --version 2. **Press F5 in VS Code** (opens Extension Development Host) 3. **Check the Debug Console** for activation logs: - - ✅ `ExcelMcp extension is now active` + - ✅ `PptMcp extension is now active` - ❌ NO errors about "Cannot read properties of undefined" 4. **In the Extension Development Host**: - Check if extension is loaded: Extensions panel - Check if MCP server is registered: Settings → MCP - - Ask GitHub Copilot to list Excel tools + - Ask GitHub Copilot to list PowerPoint tools 5. **Check Developer Tools Console** (Ctrl+Shift+I): - Go to Console tab - - Look for "ExcelMcp:" messages + - Look for "PptMcp:" messages - Verify no errors ### Package Testing @@ -160,12 +160,12 @@ vscode-extension/bin/excelcli.exe --version 2. **Install from VSIX**: - `Ctrl+Shift+P` → "Install from VSIX" - - Select `excelmcp-1.0.0.vsix` + - Select `PptMcp-1.0.0.vsix` 3. **Verify**: - Extension appears in Extensions panel - Welcome message shows on first activation - - GitHub Copilot can access Excel tools + - GitHub Copilot can access PowerPoint tools ## Publishing @@ -352,12 +352,12 @@ When VS Code releases new API features: - Verify extension ID matches registration **MCP server not found** -- Ensure bundled executable exists in `bin/Sbroenne.ExcelMcp.McpServer.exe` +- Ensure bundled executable exists in `bin/PptMcp.McpServer.exe` - Run `npm run build:all` to build both MCP server and CLI executables -- Verify bundled executable runs: `bin/Sbroenne.ExcelMcp.McpServer.exe --version` +- Verify bundled executable runs: `bin/PptMcp.McpServer.exe --version` **CLI not found** -- Ensure `bin/excelcli.exe` exists +- Ensure `bin/pptcli.exe` exists - Run `npm run build:all` to build both executables ## Extension Size @@ -368,7 +368,7 @@ The extension includes: - Main extension code (~10 KB) - Bundled self-contained MCP server (~118 MB uncompressed, ~34 MB compressed) - Bundled self-contained CLI (~115 MB uncompressed, ~34 MB compressed) -- Agent Skills (~130 KB for both excel-mcp and excel-cli) +- Agent Skills (~130 KB for both ppt-mcp and ppt-cli) Benefits of self-contained bundled approach: - ✅ Zero-setup installation (no .NET runtime or SDK required) @@ -384,7 +384,6 @@ Potential improvements: - [ ] Status bar item showing server status - [ ] Commands to restart/reload MCP server - [ ] Settings for custom tool arguments -- [ ] Telemetry for usage insights - [ ] Automatic update notifications ## References diff --git a/vscode-extension/MARKETPLACE-PUBLISHING.md b/vscode-extension/MARKETPLACE-PUBLISHING.md index 2b4138ef..a98d18a5 100644 --- a/vscode-extension/MARKETPLACE-PUBLISHING.md +++ b/vscode-extension/MARKETPLACE-PUBLISHING.md @@ -33,7 +33,7 @@ The release workflow requires the following secret to be configured in your GitH 4. **Create a publisher account** (if you don't have one) - Go to https://marketplace.visualstudio.com/manage - Click "Create publisher" - - Publisher ID: Should match `package.json` publisher field (e.g., `sbroenne`) + - Publisher ID: Should match `package.json` publisher field (e.g., `trsdn`) - Display name, description, etc. 5. **Add to GitHub Secrets** diff --git a/vscode-extension/PUBLISHER-GUIDE.md b/vscode-extension/PUBLISHER-GUIDE.md index cde1dd21..13c6387b 100644 --- a/vscode-extension/PUBLISHER-GUIDE.md +++ b/vscode-extension/PUBLISHER-GUIDE.md @@ -60,7 +60,7 @@ This guide walks you through publishing your VS Code extension to the marketplac 2. **Sign in** with the same Microsoft account from Step 1 3. Click **"Create publisher"** 4. **Fill in the form:** - - **Publisher ID**: `sbroenne` (must match the `publisher` field in `package.json`) + - **Publisher ID**: `trsdn` (must match the `publisher` field in `package.json`) - ⚠️ This MUST be exactly what's in your package.json - ⚠️ Cannot be changed later - Can only contain letters, numbers, and hyphens @@ -77,7 +77,7 @@ This guide walks you through publishing your VS Code extension to the marketplac **Why needed:** GitHub Actions needs the PAT to publish. -1. **Go to your GitHub repository**: https://github.com/sbroenne/mcp-server-excel +1. **Go to your GitHub repository**: https://github.com/trsdn/mcp-server-ppt 2. Click **Settings** (top right, near the repo name) 3. In left sidebar, click **Secrets and variables** → **Actions** 4. Click **"New repository secret"** @@ -125,8 +125,8 @@ This guide walks you through publishing your VS Code extension to the marketplac - Create unified GitHub release with all artifacts 6. **Verify publication** (takes 5-15 minutes): - - VS Code Marketplace: https://marketplace.visualstudio.com/items?itemName=sbroenne.excelmcp - - Or search "ExcelMcp" in VS Code Extensions panel + - VS Code Marketplace: https://marketplace.visualstudio.com/items?itemName=PptMcp + - Or search "PptMcp" in VS Code Extensions panel **✅ Your extension is now live on the marketplace!** @@ -163,23 +163,23 @@ git push origin v1.0.1 ## Verifying Your Publisher Account **Check your publisher page:** -- Go to https://marketplace.visualstudio.com/manage/publishers/sbroenne +- Go to https://marketplace.visualstudio.com/manage/publishers/trsdn - You should see your publisher details - Any published extensions will appear here **Check extension page:** -- Go to https://marketplace.visualstudio.com/items?itemName=sbroenne.excelmcp +- Go to https://marketplace.visualstudio.com/items?itemName=PptMcp - Should show your extension (after first publish) --- ## Common First-Time Issues -### ❌ "Publisher 'sbroenne' not found" +### ❌ "Publisher 'trsdn' not found" **Solution:** - Go to https://marketplace.visualstudio.com/manage -- Verify you created a publisher with ID `sbroenne` (exact match to package.json) +- Verify you created a publisher with ID `trsdn` (exact match to package.json) - Make sure you're signed in with the correct Microsoft account ### ❌ "Personal Access Token expired or invalid" @@ -218,7 +218,7 @@ git push origin v1.0.1 ### Update Publisher Details -1. Go to https://marketplace.visualstudio.com/manage/publishers/sbroenne +1. Go to https://marketplace.visualstudio.com/manage/publishers/trsdn 2. Click "Edit" to update: - Display name - Description @@ -227,7 +227,7 @@ git push origin v1.0.1 ### View Extension Statistics -1. Go to https://marketplace.visualstudio.com/manage/publishers/sbroenne +1. Go to https://marketplace.visualstudio.com/manage/publishers/trsdn 2. Click on your extension 3. See: - Download/install counts diff --git a/vscode-extension/README.md b/vscode-extension/README.md index 1b964629..fc511e29 100644 --- a/vscode-extension/README.md +++ b/vscode-extension/README.md @@ -1,46 +1,46 @@ -# Excel MCP Server - AI-Powered Excel Automation +# PowerPoint MCP Server - AI-Powered PowerPoint Automation -[![GitHub](https://img.shields.io/badge/GitHub-sbroenne%2Fmcp--server--excel-blue)](https://github.com/sbroenne/mcp-server-excel) +[![GitHub](https://img.shields.io/badge/GitHub-trsdn%2Fmcp--server--ppt-blue)](https://github.com/trsdn/mcp-server-ppt) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -**Control Microsoft Excel with AI through GitHub Copilot - just ask in natural language!** +**Control Microsoft PowerPoint with AI through GitHub Copilot - just ask in natural language!** -**MCP Server for Excel** enables AI assistants (GitHub Copilot, Claude, ChatGPT) to automate Excel through natural language commands. Automate Power Query, DAX measures, VBA macros, PivotTables, Charts, formatting, and data transformations - no Excel programming knowledge required. +**MCP Server for PowerPoint** enables AI assistants (GitHub Copilot, Claude, ChatGPT) to automate PowerPoint through natural language commands. Automate slide creation, layouts, shapes, text, charts, formatting, and transitions - no PowerPoint programming knowledge required. -**🛡️ 100% Safe - Uses Excel's Native COM API** - Zero risk of file corruption. Unlike third-party libraries that manipulate `.xlsx` files directly, this project uses Excel's official API ensuring complete safety and compatibility. +**🛡️ 100% Safe - Uses PowerPoint's Native COM API** - Zero risk of file corruption. Unlike third-party libraries that manipulate `.pptx` files directly, this project uses PowerPoint's official API ensuring complete safety and compatibility. -**💡 Interactive Development** - See results instantly in Excel. Create a query, run it, inspect the output, refine and repeat. Excel becomes your AI-powered workspace for rapid development and testing. +**💡 Interactive Development** - See results instantly in PowerPoint. Create a slide, run it, inspect the output, refine and repeat. PowerPoint becomes your AI-powered workspace for rapid development and testing. -**🧪 LLM-Tested Quality** - Tool behavior validated with real LLM workflows using [pytest-aitest](https://github.com/sbroenne/pytest-aitest). We test that LLMs correctly understand and use our tools. +**🧪 LLM-Tested Quality** - Tool behavior validated with real LLM workflows using [pytest-aitest](https://github.com/trsdn/pytest-aitest). We test that LLMs correctly understand and use our tools. ## Features -The Excel MCP Server (excel-mcp) provides **25 specialized tools with 225 operations** for comprehensive Excel automation: +The PowerPoint MCP Server (ppt-mcp) provides **25 specialized tools with 225 operations** for comprehensive PowerPoint automation: - 🔄 **Power Query** (1 tool, 11 ops) - Atomic workflows, M code management, load destinations - 📊 **Data Model/DAX** (2 tools, 18 ops) - Measures, relationships, model structure -- 🎨 **Excel Tables** (2 tools, 27 ops) - Lifecycle, filtering, sorting, structured references +- 🎨 **PowerPoint Tables** (2 tools, 27 ops) - Lifecycle, filtering, sorting, structured references - 📈 **PivotTables** (3 tools, 30 ops) - Creation, fields, aggregations, calculated members/fields - 📉 **Charts** (2 tools, 26 ops) - Create, configure, series, formatting, data labels, trendlines - 📝 **VBA** (1 tool, 6 ops) - Modules, execution, version control - 📋 **Ranges** (4 tools, 42 ops) - Values, formulas, formatting, validation, protection -- 📄 **Worksheets** (2 tools, 16 ops) - Lifecycle, colors, visibility, cross-workbook moves +- 📄 **Slides** (2 tools, 16 ops) - Lifecycle, colors, visibility, cross-presentation moves - 🔌 **Connections** (1 tool, 9 ops) - OLEDB/ODBC management and refresh - 🏷️ **Named Ranges** (1 tool, 6 ops) - Parameters and configuration -- 📁 **Files** (1 tool, 6 ops) - Session management, workbook creation, IRM/AIP-protected file support +- 📁 **Files** (1 tool, 6 ops) - Session management, presentation creation, IRM/AIP-protected file support - 🎚️ **Slicers** (1 tool, 8 ops) - Interactive filtering for PivotTables and Tables - 🎨 **Conditional Formatting** (1 tool, 2 ops) - Rules and clearing - 📸 **Screenshot** (1 tool, 2 ops) - Capture ranges/sheets as PNG for visual verification -- 🪧 **Window Management** (1 tool, 9 ops) - Show/hide Excel, arrange, position, status bar feedback +- 🪧 **Window Management** (1 tool, 9 ops) - Show/hide PowerPoint, arrange, position, status bar feedback -📚 **[Complete Feature Reference →](https://github.com/sbroenne/mcp-server-excel/blob/main/FEATURES.md)** +📚 **[Complete Feature Reference →](https://github.com/trsdn/mcp-server-ppt/blob/main/FEATURES.md)** ### Agent Skills (Bundled) This extension includes an **Agent Skill** following the [agentskills.io](https://agentskills.io) specification - providing domain-specific guidance for AI assistants: -- **[excel-mcp](https://github.com/sbroenne/mcp-server-excel/blob/main/skills/excel-mcp/SKILL.md)** - MCP Server tool guidance +- **[ppt-mcp](https://github.com/trsdn/mcp-server-ppt/blob/main/skills/ppt-mcp/SKILL.md)** - MCP Server tool guidance **VS Code setup:** Enable the preview setting `chat.useAgentSkills` to allow Copilot to load skills. Skills are registered via VS Code's `chatSkills` contribution point and managed automatically. @@ -48,7 +48,7 @@ This extension includes an **Agent Skill** following the [agentskills.io](https: ## 💬 Example Prompts **Create & Populate Data:** -- *"Create a new Excel file called SalesTracker.xlsx with a table for Date, Product, Quantity, Unit Price, and Total"* +- *"Create a new PowerPoint file called SalesTracker.pptx with slides for Date, Product, Quantity, Unit Price, and Total"* - *"Put this data in A1:C4 - Name, Age, City / Alice, 30, Seattle / Bob, 25, Portland"* - *"Add sample data and a formula column for Quantity times Unit Price"* @@ -60,53 +60,51 @@ This extension includes an **Agent Skill** following the [agentskills.io](https: **Formatting & Automation:** - *"Format the Price column as currency and highlight values over $500 in green"* - *"Export all Power Query M code to files for version control"* -- *"Show me Excel while you work"* - watch changes in real-time +- *"Show me PowerPoint while you work"* - watch changes in real-time ## Quick Start 1. **Install this extension** (you just did!) 2. **Ask Copilot** in the chat panel: - - "List all Power Query queries in workbook.xlsx" + - "List all Power Query queries in presentation.pptx" - "Create a DAX measure for year-over-year revenue growth" - "Export all Power Queries and VBA modules to .vba files for version control" **That's it!** The extension includes a self-contained MCP server - no .NET runtime or SDK needed. -➡️ **[Learn more and see examples](https://sbroenne.github.io/mcp-server-excel/)** +➡️ **[Learn more and see examples](https://trsdn.github.io/mcp-server-ppt/)** ## Requirements -- **Windows OS** - Excel COM automation requires Windows -- **Microsoft Excel 2016+** - Must be installed on your system +- **Windows OS** - PowerPoint COM automation requires Windows +- **Microsoft PowerPoint 2016+** - Must be installed on your system ## Potential Issues -**"Excel is not installed" error:** -- Ensure Microsoft Excel 2016+ is installed on your Windows machine -- Try opening Excel manually to verify it works +**"PowerPoint is not installed" error:** +- Ensure Microsoft PowerPoint 2016+ is installed on your Windows machine +- Try opening PowerPoint manually to verify it works **"VBA access denied" error:** -- VBA operations require one-time manual setup in Excel +- VBA operations require one-time manual setup in PowerPoint - Go to: File → Options → Trust Center → Trust Center Settings → Macro Settings - Check "Trust access to the VBA project object model" -**Copilot doesn't see Excel tools:** +**Copilot doesn't see PowerPoint tools:** - Restart VS Code after installing the extension - ### Troubleshooting -- Check Output panel → "Excel MCP Server" for connection status +- Check Output panel → "PowerPoint MCP Server" for connection status ## Documentation & Support -- **[Complete Documentation](https://github.com/sbroenne/mcp-server-excel)** - Full guides and examples -- **[Report Issues](https://github.com/sbroenne/mcp-server-excel/issues)** - Bug reports and feature requests +- **[Complete Documentation](https://github.com/trsdn/mcp-server-ppt)** - Full guides and examples +- **[Report Issues](https://github.com/trsdn/mcp-server-ppt/issues)** - Bug reports and feature requests ## License & Privacy -MIT License - see [LICENSE](https://github.com/sbroenne/mcp-server-excel/blob/main/LICENSE) - -Privacy Policy - see [PRIVACY.md](https://github.com/sbroenne/mcp-server-excel/blob/main/PRIVACY.md) +MIT License - see [LICENSE](https://github.com/trsdn/mcp-server-ppt/blob/main/LICENSE) --- diff --git a/vscode-extension/package-lock.json b/vscode-extension/package-lock.json index 1fecd349..d6e6bb3c 100644 --- a/vscode-extension/package-lock.json +++ b/vscode-extension/package-lock.json @@ -1,11 +1,11 @@ { - "name": "excel-mcp", + "name": "ppt-mcp", "version": "1.6.9", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "excel-mcp", + "name": "ppt-mcp", "version": "1.6.9", "license": "MIT", "os": [ diff --git a/vscode-extension/package.json b/vscode-extension/package.json index d41c6db1..c18ba827 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -1,18 +1,18 @@ { - "name": "excel-mcp", - "displayName": "Excel MCP Server", - "description": "Excel automation for AI assistants - manage Sheets, Power Query, DAX, VBA, PowerPivot, Tables, Ranges, Charts, Formatting, Validation & more - requires Excel to be installed", + "name": "ppt-mcp", + "displayName": "PowerPoint MCP Server", + "description": "PowerPoint automation for AI assistants - manage Slides, Shapes, Text, Charts, Layouts, Transitions, Animations, Formatting & more - requires PowerPoint to be installed", "version": "1.6.9", - "publisher": "sbroenne", + "publisher": "trsdn", "icon": "icon.png", "repository": { "type": "git", - "url": "https://github.com/sbroenne/mcp-server-excel" + "url": "https://github.com/trsdn/mcp-server-ppt" }, "bugs": { - "url": "https://github.com/sbroenne/mcp-server-excel/issues" + "url": "https://github.com/trsdn/mcp-server-ppt/issues" }, - "homepage": "https://excelmcpserver.dev/", + "homepage": "https://PptMcpserver.dev/", "license": "MIT", "engines": { "vscode": "^1.109.0" @@ -28,23 +28,23 @@ "keywords": [ "mcp", "model context protocol", - "excel", + "powerpoint", "microsoft", "office", - "spreadsheet", + "presentation", "automation", - "power query", - "m language", - "dax", - "data model", - "power pivot", "vba", "macro", "table", "copilot", "ai", "github copilot", - "data analysis" + "slides", + "shapes", + "animations", + "transitions", + "smartart", + "presentations" ], "activationEvents": [ "onStartupFinished" @@ -53,21 +53,21 @@ "contributes": { "mcpServerDefinitionProviders": [ { - "id": "excel-mcp", - "label": "Excel MCP Server" + "id": "ppt-mcp", + "label": "PowerPoint MCP Server" } ], "chatSkills": [ { - "path": "./skills/excel-mcp/SKILL.md" + "path": "./skills/ppt-mcp/SKILL.md" } ] }, "scripts": { - "clean": "cd .. && dotnet build-server shutdown && dotnet clean src/ExcelMcp.McpServer/ExcelMcp.McpServer.csproj -c Release -nodeReuse:false", + "clean": "cd .. && dotnet build-server shutdown && dotnet clean src/PptMcp.McpServer/PptMcp.McpServer.csproj -c Release -nodeReuse:false", "clean:bin": "node -e \"const fs=require('fs'); try{fs.rmSync('bin',{recursive:true,force:true})}catch{}\"", - "copy:skills": "xcopy /E /I /Y ..\\skills\\excel-mcp skills\\excel-mcp", - "build:mcp-server": "npm run clean && npm run clean:bin && cd .. && dotnet publish src/ExcelMcp.McpServer/ExcelMcp.McpServer.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishTrimmed=false -p:PublishReadyToRun=false -p:NuGetAudit=false -nodeReuse:false -o vscode-extension/bin --verbosity minimal", + "copy:skills": "xcopy /E /I /Y ..\\skills\\ppt-mcp skills\\ppt-mcp", + "build:mcp-server": "npm run clean && npm run clean:bin && cd .. && dotnet publish src/PptMcp.McpServer/PptMcp.McpServer.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishTrimmed=false -p:PublishReadyToRun=false -p:NuGetAudit=false -nodeReuse:false -o vscode-extension/bin --verbosity minimal", "copy:changelog": "node -e \"const fs=require('fs'); fs.copyFileSync('../CHANGELOG.md','CHANGELOG.md');\"", "vscode:prepublish": "npm run build:mcp-server && npm run copy:skills && npm run copy:changelog && npm run compile", "compile": "tsc -p ./", diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index 938794bb..fa018500 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -2,10 +2,10 @@ import * as vscode from 'vscode'; import * as path from 'path'; /** - * ExcelMcp VS Code Extension + * PptMcp VS Code Extension * - * This extension provides MCP server definitions for the ExcelMcp MCP server, - * enabling AI assistants like GitHub Copilot to interact with Microsoft Excel + * This extension provides MCP server definitions for the PptMcp MCP server, + * enabling AI assistants like GitHub Copilot to interact with Microsoft PowerPoint * through native COM automation. * * The extension bundles self-contained executables for both the MCP server and CLI - @@ -15,19 +15,19 @@ import * as path from 'path'; */ export async function activate(context: vscode.ExtensionContext) { - console.log('ExcelMcp extension is now active'); + console.log('PptMcp extension is now active'); // Register MCP server definition provider context.subscriptions.push( - vscode.lm.registerMcpServerDefinitionProvider('excel-mcp', { + vscode.lm.registerMcpServerDefinitionProvider('ppt-mcp', { provideMcpServerDefinitions: async () => { - // Return the MCP server definition for ExcelMcp + // Return the MCP server definition for PptMcp const extensionPath = context.extensionPath; - const mcpServerPath = path.join(extensionPath, 'bin', 'Sbroenne.ExcelMcp.McpServer.exe'); + const mcpServerPath = path.join(extensionPath, 'bin', 'PptMcp.McpServer.exe'); return [ new vscode.McpStdioServerDefinition( - 'excel-mcp', + 'ppt-mcp', mcpServerPath, [], { @@ -40,24 +40,24 @@ export async function activate(context: vscode.ExtensionContext) { ); // Show welcome message on first activation - const hasShownWelcome = context.globalState.get('excelmcp.hasShownWelcome', false); + const hasShownWelcome = context.globalState.get('pptmcp.hasShownWelcome', false); if (!hasShownWelcome) { showWelcomeMessage(); - context.globalState.update('excelmcp.hasShownWelcome', true); + context.globalState.update('pptmcp.hasShownWelcome', true); } } function showWelcomeMessage() { - const message = 'ExcelMcp extension activated! The Excel MCP server is now available for AI assistants.'; + const message = 'PptMcp extension activated! The PowerPoint MCP server is now available for AI assistants.'; const learnMore = 'Learn More'; vscode.window.showInformationMessage(message, learnMore).then(selection => { if (selection === learnMore) { - vscode.env.openExternal(vscode.Uri.parse('https://github.com/sbroenne/mcp-server-excel')); + vscode.env.openExternal(vscode.Uri.parse('https://github.com/sbroenne/mcp-server-ppt')); } }); } export function deactivate() { - console.log('ExcelMcp extension is now deactivated'); + console.log('PptMcp extension is now deactivated'); }