chore: complete v1.0 milestone
All checks were successful
Release zip package / release (push) Successful in 10s

Archive 5 phases (36 plans) to milestones/v1.0-phases/.
Archive roadmap, requirements, and audit to milestones/.
Evolve PROJECT.md with shipped state and validated requirements.
Collapse ROADMAP.md to one-line milestone summary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dev
2026-04-07 09:15:14 +02:00
parent b815c323d7
commit 655bb79a99
95 changed files with 610 additions and 332 deletions

View File

@@ -0,0 +1,815 @@
---
phase: 03
plan: 01
title: Wave 0 — Test Scaffolds, Stub Interfaces, and Core Models
status: pending
wave: 0
depends_on: []
files_modified:
- SharepointToolbox/Core/Models/StorageNode.cs
- SharepointToolbox/Core/Models/StorageScanOptions.cs
- SharepointToolbox/Core/Models/SearchResult.cs
- SharepointToolbox/Core/Models/SearchOptions.cs
- SharepointToolbox/Core/Models/DuplicateGroup.cs
- SharepointToolbox/Core/Models/DuplicateItem.cs
- SharepointToolbox/Core/Models/DuplicateScanOptions.cs
- SharepointToolbox/Services/IStorageService.cs
- SharepointToolbox/Services/ISearchService.cs
- SharepointToolbox/Services/IDuplicatesService.cs
- SharepointToolbox/Services/Export/StorageCsvExportService.cs
- SharepointToolbox/Services/Export/StorageHtmlExportService.cs
- SharepointToolbox/Services/Export/SearchCsvExportService.cs
- SharepointToolbox/Services/Export/SearchHtmlExportService.cs
- SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs
- SharepointToolbox.Tests/Services/StorageServiceTests.cs
- SharepointToolbox.Tests/Services/SearchServiceTests.cs
- SharepointToolbox.Tests/Services/DuplicatesServiceTests.cs
- SharepointToolbox.Tests/Services/Export/StorageCsvExportServiceTests.cs
- SharepointToolbox.Tests/Services/Export/StorageHtmlExportServiceTests.cs
- SharepointToolbox.Tests/Services/Export/SearchExportServiceTests.cs
- SharepointToolbox.Tests/Services/Export/DuplicatesHtmlExportServiceTests.cs
autonomous: true
requirements:
- STOR-01
- STOR-02
- STOR-03
- STOR-04
- STOR-05
- SRCH-01
- SRCH-02
- SRCH-03
- SRCH-04
- DUPL-01
- DUPL-02
- DUPL-03
must_haves:
truths:
- "dotnet build produces 0 errors after all 7 models, 3 interfaces, and 5 stub export classes are created"
- "All 7 test files exist and are discovered by dotnet test (test count > 0)"
- "StorageServiceTests, SearchServiceTests, DuplicatesServiceTests compile but skip (stubs referencing types that exist after this plan)"
- "The pure-logic tests in DuplicatesServiceTests (MakeKey composite key) are real [Fact] tests — not skipped — and pass"
- "Export service tests compile but fail (types exist as stubs with no real implementation yet) — expected until Plans 03/05"
artifacts:
- path: "SharepointToolbox/Core/Models/StorageNode.cs"
provides: "Tree node model for storage metrics display"
- path: "SharepointToolbox/Core/Models/SearchResult.cs"
provides: "Flat result record for file search output"
- path: "SharepointToolbox/Core/Models/DuplicateGroup.cs"
provides: "Group record for duplicate detection output"
- path: "SharepointToolbox/Services/IStorageService.cs"
provides: "Interface enabling ViewModel mocking for storage"
- path: "SharepointToolbox/Services/ISearchService.cs"
provides: "Interface enabling ViewModel mocking for search"
- path: "SharepointToolbox/Services/IDuplicatesService.cs"
provides: "Interface enabling ViewModel mocking for duplicates"
key_links:
- from: "StorageServiceTests.cs"
to: "IStorageService"
via: "mock interface"
pattern: "IStorageService"
- from: "SearchServiceTests.cs"
to: "ISearchService"
via: "mock interface"
pattern: "ISearchService"
- from: "DuplicatesServiceTests.cs"
to: "MakeKey"
via: "static pure function"
pattern: "MakeKey"
---
# Plan 03-01: Wave 0 — Test Scaffolds, Stub Interfaces, and Core Models
## Goal
Create all data models, service interfaces, export service stubs, and test scaffolds needed so every subsequent plan has a working `dotnet test --filter` verify command pointing at a real test class. Interfaces and models define the contracts; implementation plans (03-02 through 03-05) fill them in. One set of pure-logic tests (the `MakeKey` composite key function for duplicate detection) are real `[Fact]` tests that pass immediately since the logic is pure and has no CSOM dependencies.
## Context
Phase 2 created `PermissionEntry`, `ScanOptions`, `IPermissionsService`, and test scaffolds in exactly this pattern. Phase 3 follows the same Wave 0 approach: models + interfaces first, implementation in subsequent plans. The test project at `SharepointToolbox.Tests/SharepointToolbox.Tests.csproj` already has xUnit 2.9.3 + Moq. The export service stubs must compile (the test files reference them) even though their `BuildCsv`/`BuildHtml` methods return empty strings until implemented.
## Tasks
### Task 1: Create all 7 core models and 3 service interfaces
**Files:**
- `SharepointToolbox/Core/Models/StorageNode.cs`
- `SharepointToolbox/Core/Models/StorageScanOptions.cs`
- `SharepointToolbox/Core/Models/SearchResult.cs`
- `SharepointToolbox/Core/Models/SearchOptions.cs`
- `SharepointToolbox/Core/Models/DuplicateGroup.cs`
- `SharepointToolbox/Core/Models/DuplicateItem.cs`
- `SharepointToolbox/Core/Models/DuplicateScanOptions.cs`
- `SharepointToolbox/Services/IStorageService.cs`
- `SharepointToolbox/Services/ISearchService.cs`
- `SharepointToolbox/Services/IDuplicatesService.cs`
**Action:** Create | Write
**Why:** All subsequent plans depend on these contracts. Tests must compile against them. Interfaces enable Moq-based unit tests.
```csharp
// SharepointToolbox/Core/Models/StorageNode.cs
namespace SharepointToolbox.Core.Models;
public class StorageNode
{
public string Name { get; set; } = string.Empty;
public string Url { get; set; } = string.Empty;
public string SiteTitle { get; set; } = string.Empty;
public string Library { get; set; } = string.Empty;
public long TotalSizeBytes { get; set; }
public long FileStreamSizeBytes { get; set; }
public long VersionSizeBytes => Math.Max(0L, TotalSizeBytes - FileStreamSizeBytes);
public long TotalFileCount { get; set; }
public DateTime? LastModified { get; set; }
public int IndentLevel { get; set; }
public List<StorageNode> Children { get; set; } = new();
}
```
```csharp
// SharepointToolbox/Core/Models/StorageScanOptions.cs
namespace SharepointToolbox.Core.Models;
public record StorageScanOptions(
bool PerLibrary = true,
bool IncludeSubsites = false,
int FolderDepth = 0 // 0 = library root only; >0 = recurse N levels
);
```
```csharp
// SharepointToolbox/Core/Models/SearchResult.cs
namespace SharepointToolbox.Core.Models;
public class SearchResult
{
public string Title { get; set; } = string.Empty;
public string Path { get; set; } = string.Empty;
public string FileExtension { get; set; } = string.Empty;
public DateTime? Created { get; set; }
public DateTime? LastModified { get; set; }
public string Author { get; set; } = string.Empty;
public string ModifiedBy { get; set; } = string.Empty;
public long SizeBytes { get; set; }
}
```
```csharp
// SharepointToolbox/Core/Models/SearchOptions.cs
namespace SharepointToolbox.Core.Models;
public record SearchOptions(
string[] Extensions,
string? Regex,
DateTime? CreatedAfter,
DateTime? CreatedBefore,
DateTime? ModifiedAfter,
DateTime? ModifiedBefore,
string? CreatedBy,
string? ModifiedBy,
string? Library,
int MaxResults,
string SiteUrl
);
```
```csharp
// SharepointToolbox/Core/Models/DuplicateItem.cs
namespace SharepointToolbox.Core.Models;
public class DuplicateItem
{
public string Name { get; set; } = string.Empty;
public string Path { get; set; } = string.Empty;
public string Library { get; set; } = string.Empty;
public long? SizeBytes { get; set; }
public DateTime? Created { get; set; }
public DateTime? Modified { get; set; }
public int? FolderCount { get; set; }
public int? FileCount { get; set; }
}
```
```csharp
// SharepointToolbox/Core/Models/DuplicateGroup.cs
namespace SharepointToolbox.Core.Models;
public class DuplicateGroup
{
public string GroupKey { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public List<DuplicateItem> Items { get; set; } = new();
}
```
```csharp
// SharepointToolbox/Core/Models/DuplicateScanOptions.cs
namespace SharepointToolbox.Core.Models;
public record DuplicateScanOptions(
string Mode = "Files", // "Files" or "Folders"
bool MatchSize = true,
bool MatchCreated = false,
bool MatchModified = false,
bool MatchSubfolderCount = false,
bool MatchFileCount = false,
bool IncludeSubsites = false,
string? Library = null
);
```
```csharp
// SharepointToolbox/Services/IStorageService.cs
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
public interface IStorageService
{
Task<IReadOnlyList<StorageNode>> CollectStorageAsync(
ClientContext ctx,
StorageScanOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct);
}
```
```csharp
// SharepointToolbox/Services/ISearchService.cs
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
public interface ISearchService
{
Task<IReadOnlyList<SearchResult>> SearchFilesAsync(
ClientContext ctx,
SearchOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct);
}
```
```csharp
// SharepointToolbox/Services/IDuplicatesService.cs
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
public interface IDuplicatesService
{
Task<IReadOnlyList<DuplicateGroup>> ScanDuplicatesAsync(
ClientContext ctx,
DuplicateScanOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct);
}
```
**Verification:**
```bash
dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx
```
Expected: 0 errors
### Task 2: Create 5 export service stubs and 7 test scaffold files
**Files:**
- `SharepointToolbox/Services/Export/StorageCsvExportService.cs`
- `SharepointToolbox/Services/Export/StorageHtmlExportService.cs`
- `SharepointToolbox/Services/Export/SearchCsvExportService.cs`
- `SharepointToolbox/Services/Export/SearchHtmlExportService.cs`
- `SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs`
- `SharepointToolbox.Tests/Services/StorageServiceTests.cs`
- `SharepointToolbox.Tests/Services/SearchServiceTests.cs`
- `SharepointToolbox.Tests/Services/DuplicatesServiceTests.cs`
- `SharepointToolbox.Tests/Services/Export/StorageCsvExportServiceTests.cs`
- `SharepointToolbox.Tests/Services/Export/StorageHtmlExportServiceTests.cs`
- `SharepointToolbox.Tests/Services/Export/SearchExportServiceTests.cs`
- `SharepointToolbox.Tests/Services/Export/DuplicatesHtmlExportServiceTests.cs`
**Action:** Create | Write
**Why:** Stubs enable test files to compile. The `MakeKey` helper and `VersionSizeBytes` derived property can be unit tested immediately without any CSOM. Export service tests will fail until plans 03-03 and 03-05 implement the real logic — that is the expected state.
```csharp
// SharepointToolbox/Services/Export/StorageCsvExportService.cs
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services.Export;
public class StorageCsvExportService
{
public string BuildCsv(IReadOnlyList<StorageNode> nodes) => string.Empty; // implemented in Plan 03-03
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct)
{
var csv = BuildCsv(nodes);
await System.IO.File.WriteAllTextAsync(filePath, csv, new System.Text.UTF8Encoding(true), ct);
}
}
```
```csharp
// SharepointToolbox/Services/Export/StorageHtmlExportService.cs
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services.Export;
public class StorageHtmlExportService
{
public string BuildHtml(IReadOnlyList<StorageNode> nodes) => string.Empty; // implemented in Plan 03-03
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct)
{
var html = BuildHtml(nodes);
await System.IO.File.WriteAllTextAsync(filePath, html, System.Text.Encoding.UTF8, ct);
}
}
```
```csharp
// SharepointToolbox/Services/Export/SearchCsvExportService.cs
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services.Export;
public class SearchCsvExportService
{
public string BuildCsv(IReadOnlyList<SearchResult> results) => string.Empty; // implemented in Plan 03-05
public async Task WriteAsync(IReadOnlyList<SearchResult> results, string filePath, CancellationToken ct)
{
var csv = BuildCsv(results);
await System.IO.File.WriteAllTextAsync(filePath, csv, new System.Text.UTF8Encoding(true), ct);
}
}
```
```csharp
// SharepointToolbox/Services/Export/SearchHtmlExportService.cs
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services.Export;
public class SearchHtmlExportService
{
public string BuildHtml(IReadOnlyList<SearchResult> results) => string.Empty; // implemented in Plan 03-05
public async Task WriteAsync(IReadOnlyList<SearchResult> results, string filePath, CancellationToken ct)
{
var html = BuildHtml(results);
await System.IO.File.WriteAllTextAsync(filePath, html, System.Text.Encoding.UTF8, ct);
}
}
```
```csharp
// SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services.Export;
public class DuplicatesHtmlExportService
{
public string BuildHtml(IReadOnlyList<DuplicateGroup> groups) => string.Empty; // implemented in Plan 03-05
public async Task WriteAsync(IReadOnlyList<DuplicateGroup> groups, string filePath, CancellationToken ct)
{
var html = BuildHtml(groups);
await System.IO.File.WriteAllTextAsync(filePath, html, System.Text.Encoding.UTF8, ct);
}
}
```
Now the test scaffold files. The `DuplicatesServiceTests` includes a real pure-logic test for `MakeKey` — define the helper class inline in the same file so it compiles without depending on `DuplicatesService`:
```csharp
// SharepointToolbox.Tests/Services/StorageServiceTests.cs
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using Xunit;
namespace SharepointToolbox.Tests.Services;
public class StorageServiceTests
{
[Fact(Skip = "Requires live CSOM context — covered by Plan 03-02 implementation")]
public Task CollectStorageAsync_ReturnsLibraryNodes_ForDocumentLibraries()
=> Task.CompletedTask;
[Fact(Skip = "Requires live CSOM context — covered by Plan 03-02 implementation")]
public Task CollectStorageAsync_WithFolderDepth1_ReturnsSubfolderNodes()
=> Task.CompletedTask;
[Fact]
public void StorageNode_VersionSizeBytes_IsNonNegative()
{
// VersionSizeBytes = TotalSizeBytes - FileStreamSizeBytes (never negative)
var node = new StorageNode { TotalSizeBytes = 1000L, FileStreamSizeBytes = 1200L };
Assert.Equal(0L, node.VersionSizeBytes); // Math.Max(0, -200) = 0
}
[Fact]
public void StorageNode_VersionSizeBytes_IsCorrectWhenPositive()
{
var node = new StorageNode { TotalSizeBytes = 5000L, FileStreamSizeBytes = 3000L };
Assert.Equal(2000L, node.VersionSizeBytes);
}
}
```
```csharp
// SharepointToolbox.Tests/Services/SearchServiceTests.cs
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using Xunit;
namespace SharepointToolbox.Tests.Services;
public class SearchServiceTests
{
[Fact(Skip = "Requires live CSOM context — covered by Plan 03-04 implementation")]
public Task SearchFilesAsync_WithExtensionFilter_BuildsCorrectKql()
=> Task.CompletedTask;
[Fact(Skip = "Requires live CSOM context — covered by Plan 03-04 implementation")]
public Task SearchFilesAsync_PaginationStopsAt50000()
=> Task.CompletedTask;
[Fact(Skip = "Requires live CSOM context — covered by Plan 03-04 implementation")]
public Task SearchFilesAsync_FiltersVersionHistoryPaths()
=> Task.CompletedTask;
}
```
```csharp
// SharepointToolbox.Tests/Services/DuplicatesServiceTests.cs
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using Xunit;
namespace SharepointToolbox.Tests.Services;
/// <summary>
/// Pure-logic tests for the MakeKey composite key function (no CSOM needed).
/// Inline helper matches the implementation DuplicatesService will produce in Plan 03-04.
/// </summary>
public class DuplicatesServiceTests
{
// Inline copy of MakeKey to test logic before Plan 03-04 creates the real class
private static string MakeKey(DuplicateItem item, DuplicateScanOptions opts)
{
var parts = new System.Collections.Generic.List<string> { item.Name.ToLowerInvariant() };
if (opts.MatchSize && item.SizeBytes.HasValue) parts.Add(item.SizeBytes.Value.ToString());
if (opts.MatchCreated && item.Created.HasValue) parts.Add(item.Created.Value.Date.ToString("yyyy-MM-dd"));
if (opts.MatchModified && item.Modified.HasValue) parts.Add(item.Modified.Value.Date.ToString("yyyy-MM-dd"));
if (opts.MatchSubfolderCount && item.FolderCount.HasValue) parts.Add(item.FolderCount.Value.ToString());
if (opts.MatchFileCount && item.FileCount.HasValue) parts.Add(item.FileCount.Value.ToString());
return string.Join("|", parts);
}
[Fact]
public void MakeKey_NameOnly_ReturnsLowercaseName()
{
var item = new DuplicateItem { Name = "Report.docx", SizeBytes = 1000 };
var opts = new DuplicateScanOptions(MatchSize: false);
Assert.Equal("report.docx", MakeKey(item, opts));
}
[Fact]
public void MakeKey_WithSizeMatch_AppendsSizeToKey()
{
var item = new DuplicateItem { Name = "Report.docx", SizeBytes = 1024 };
var opts = new DuplicateScanOptions(MatchSize: true);
Assert.Equal("report.docx|1024", MakeKey(item, opts));
}
[Fact]
public void MakeKey_WithCreatedAndModified_AppendsDateStrings()
{
var item = new DuplicateItem
{
Name = "file.pdf",
SizeBytes = 500,
Created = new DateTime(2024, 3, 15),
Modified = new DateTime(2024, 6, 1)
};
var opts = new DuplicateScanOptions(MatchSize: false, MatchCreated: true, MatchModified: true);
Assert.Equal("file.pdf|2024-03-15|2024-06-01", MakeKey(item, opts));
}
[Fact]
public void MakeKey_SameKeyForSameItems_GroupsCorrectly()
{
var opts = new DuplicateScanOptions(MatchSize: true);
var item1 = new DuplicateItem { Name = "Budget.xlsx", SizeBytes = 2048 };
var item2 = new DuplicateItem { Name = "BUDGET.xlsx", SizeBytes = 2048 };
Assert.Equal(MakeKey(item1, opts), MakeKey(item2, opts));
}
[Fact]
public void MakeKey_DifferentSize_ProducesDifferentKeys()
{
var opts = new DuplicateScanOptions(MatchSize: true);
var item1 = new DuplicateItem { Name = "file.docx", SizeBytes = 100 };
var item2 = new DuplicateItem { Name = "file.docx", SizeBytes = 200 };
Assert.NotEqual(MakeKey(item1, opts), MakeKey(item2, opts));
}
[Fact(Skip = "Requires live CSOM context — covered by Plan 03-04 implementation")]
public Task ScanDuplicatesAsync_Files_GroupsByCompositeKey()
=> Task.CompletedTask;
[Fact(Skip = "Requires live CSOM context — covered by Plan 03-04 implementation")]
public Task ScanDuplicatesAsync_Folders_UsesCamlFSObjType1()
=> Task.CompletedTask;
}
```
```csharp
// SharepointToolbox.Tests/Services/Export/StorageCsvExportServiceTests.cs
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services.Export;
using Xunit;
namespace SharepointToolbox.Tests.Services.Export;
public class StorageCsvExportServiceTests
{
[Fact]
public void BuildCsv_WithKnownNodes_ProducesHeaderRow()
{
var svc = new StorageCsvExportService();
var nodes = new List<StorageNode>
{
new() { Name = "Shared Documents", Library = "Shared Documents", SiteTitle = "MySite",
TotalSizeBytes = 1024, FileStreamSizeBytes = 800, TotalFileCount = 5,
LastModified = new DateTime(2024, 1, 15) }
};
var csv = svc.BuildCsv(nodes);
Assert.Contains("Library", csv);
Assert.Contains("Site", csv);
Assert.Contains("Files", csv);
Assert.Contains("Total Size", csv);
Assert.Contains("Version Size", csv);
Assert.Contains("Last Modified", csv);
}
[Fact]
public void BuildCsv_WithEmptyList_ReturnsHeaderOnly()
{
var svc = new StorageCsvExportService();
var csv = svc.BuildCsv(new List<StorageNode>());
Assert.NotEmpty(csv); // must have at least the header row
var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Single(lines); // only header, no data rows
}
[Fact]
public void BuildCsv_NodeValues_AppearInOutput()
{
var svc = new StorageCsvExportService();
var nodes = new List<StorageNode>
{
new() { Name = "Reports", Library = "Reports", SiteTitle = "ProjectSite",
TotalSizeBytes = 2048, FileStreamSizeBytes = 1024, TotalFileCount = 10 }
};
var csv = svc.BuildCsv(nodes);
Assert.Contains("Reports", csv);
Assert.Contains("ProjectSite", csv);
Assert.Contains("10", csv);
}
}
```
```csharp
// SharepointToolbox.Tests/Services/Export/StorageHtmlExportServiceTests.cs
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services.Export;
using Xunit;
namespace SharepointToolbox.Tests.Services.Export;
public class StorageHtmlExportServiceTests
{
[Fact]
public void BuildHtml_WithNodes_ContainsToggleJs()
{
var svc = new StorageHtmlExportService();
var nodes = new List<StorageNode>
{
new() { Name = "Shared Documents", Library = "Shared Documents", SiteTitle = "Site1",
TotalSizeBytes = 5000, FileStreamSizeBytes = 4000, TotalFileCount = 20,
Children = new List<StorageNode>
{
new() { Name = "Archive", Library = "Shared Documents", SiteTitle = "Site1",
TotalSizeBytes = 1000, FileStreamSizeBytes = 800, TotalFileCount = 5 }
} }
};
var html = svc.BuildHtml(nodes);
Assert.Contains("toggle(", html);
Assert.Contains("<!DOCTYPE html>", html);
Assert.Contains("Shared Documents", html);
}
[Fact]
public void BuildHtml_WithEmptyList_ReturnsValidHtml()
{
var svc = new StorageHtmlExportService();
var html = svc.BuildHtml(new List<StorageNode>());
Assert.Contains("<!DOCTYPE html>", html);
Assert.Contains("<html", html);
}
[Fact]
public void BuildHtml_WithMultipleLibraries_EachLibraryAppearsInOutput()
{
var svc = new StorageHtmlExportService();
var nodes = new List<StorageNode>
{
new() { Name = "Documents", Library = "Documents", SiteTitle = "Site1", TotalSizeBytes = 1000 },
new() { Name = "Images", Library = "Images", SiteTitle = "Site1", TotalSizeBytes = 2000 }
};
var html = svc.BuildHtml(nodes);
Assert.Contains("Documents", html);
Assert.Contains("Images", html);
}
}
```
```csharp
// SharepointToolbox.Tests/Services/Export/SearchExportServiceTests.cs
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services.Export;
using Xunit;
namespace SharepointToolbox.Tests.Services.Export;
public class SearchExportServiceTests
{
private static SearchResult MakeSample() => new()
{
Title = "Q1 Budget.xlsx",
Path = "https://contoso.sharepoint.com/sites/Finance/Shared Documents/Q1 Budget.xlsx",
FileExtension = "xlsx",
Created = new DateTime(2024, 1, 10),
LastModified = new DateTime(2024, 3, 20),
Author = "Alice Smith",
ModifiedBy = "Bob Jones",
SizeBytes = 48_000
};
// ── CSV tests ──────────────────────────────────────────────────────────────
[Fact]
public void BuildCsv_WithKnownResults_ContainsExpectedHeader()
{
var svc = new SearchCsvExportService();
var csv = svc.BuildCsv(new List<SearchResult> { MakeSample() });
Assert.Contains("File Name", csv);
Assert.Contains("Extension", csv);
Assert.Contains("Created", csv);
Assert.Contains("Created By", csv);
Assert.Contains("Modified By", csv);
Assert.Contains("Size", csv);
}
[Fact]
public void BuildCsv_WithEmptyList_ReturnsHeaderOnly()
{
var svc = new SearchCsvExportService();
var csv = svc.BuildCsv(new List<SearchResult>());
Assert.NotEmpty(csv);
var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Single(lines);
}
[Fact]
public void BuildCsv_ResultValues_AppearInOutput()
{
var svc = new SearchCsvExportService();
var csv = svc.BuildCsv(new List<SearchResult> { MakeSample() });
Assert.Contains("Alice Smith", csv);
Assert.Contains("xlsx", csv);
}
// ── HTML tests ─────────────────────────────────────────────────────────────
[Fact]
public void BuildHtml_WithResults_ContainsSortableColumnScript()
{
var svc = new SearchHtmlExportService();
var html = svc.BuildHtml(new List<SearchResult> { MakeSample() });
Assert.Contains("<!DOCTYPE html>", html);
Assert.Contains("sort", html); // sortable columns JS
Assert.Contains("Q1 Budget.xlsx", html);
}
[Fact]
public void BuildHtml_WithResults_ContainsFilterInput()
{
var svc = new SearchHtmlExportService();
var html = svc.BuildHtml(new List<SearchResult> { MakeSample() });
Assert.Contains("filter", html); // filter input element
}
[Fact]
public void BuildHtml_WithEmptyList_ReturnsValidHtml()
{
var svc = new SearchHtmlExportService();
var html = svc.BuildHtml(new List<SearchResult>());
Assert.Contains("<!DOCTYPE html>", html);
}
}
```
```csharp
// SharepointToolbox.Tests/Services/Export/DuplicatesHtmlExportServiceTests.cs
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services.Export;
using Xunit;
namespace SharepointToolbox.Tests.Services.Export;
public class DuplicatesHtmlExportServiceTests
{
private static DuplicateGroup MakeGroup(string name, int count) => new()
{
GroupKey = $"{name}|1024",
Name = name,
Items = Enumerable.Range(1, count).Select(i => new DuplicateItem
{
Name = name,
Path = $"https://contoso.sharepoint.com/sites/Site{i}/{name}",
Library = "Shared Documents",
SizeBytes = 1024
}).ToList()
};
[Fact]
public void BuildHtml_WithGroups_ContainsGroupCards()
{
var svc = new DuplicatesHtmlExportService();
var groups = new List<DuplicateGroup> { MakeGroup("report.docx", 3) };
var html = svc.BuildHtml(groups);
Assert.Contains("<!DOCTYPE html>", html);
Assert.Contains("report.docx", html);
}
[Fact]
public void BuildHtml_WithMultipleGroups_AllGroupNamesPresent()
{
var svc = new DuplicatesHtmlExportService();
var groups = new List<DuplicateGroup>
{
MakeGroup("budget.xlsx", 2),
MakeGroup("photo.jpg", 4)
};
var html = svc.BuildHtml(groups);
Assert.Contains("budget.xlsx", html);
Assert.Contains("photo.jpg", html);
}
[Fact]
public void BuildHtml_WithEmptyList_ReturnsValidHtml()
{
var svc = new DuplicatesHtmlExportService();
var html = svc.BuildHtml(new List<DuplicateGroup>());
Assert.Contains("<!DOCTYPE html>", html);
}
}
```
**Verification:**
```bash
dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~DuplicatesServiceTests" -x
```
Expected: 5 real tests pass (MakeKey logic tests), CSOM stubs skip
## Verification
```bash
dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx
dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~DuplicatesServiceTests|FullyQualifiedName~StorageServiceTests" -x
```
Expected: 0 build errors; 7 DuplicatesServiceTests+StorageServiceTests pass or skip with no CS errors
> **Note on unfiltered test run at Wave 0:** Running `dotnet test` without a filter at this stage will show approximately 15 failing tests across `StorageCsvExportServiceTests`, `StorageHtmlExportServiceTests`, `SearchExportServiceTests`, and `DuplicatesHtmlExportServiceTests`. This is expected — all 5 export service stubs return `string.Empty` until Plans 03-03 and 03-05 implement the real logic. Do not treat these failures as a blocker for Wave 0 completion.
## Commit Message
feat(03-01): create Phase 3 models, interfaces, export stubs, and test scaffolds
## Output
After completion, create `.planning/phases/03-storage/03-01-SUMMARY.md`

View File

@@ -0,0 +1,141 @@
---
phase: 03-storage
plan: 01
subsystem: testing
tags: [csharp, xunit, moq, interfaces, models, storage, search, duplicates]
# Dependency graph
requires:
- phase: 02-permissions
provides: Phase 2 export service pattern, test scaffold pattern with Wave 0 stubs
provides:
- 7 core data models (StorageNode, StorageScanOptions, SearchResult, SearchOptions, DuplicateItem, DuplicateGroup, DuplicateScanOptions)
- 3 service interfaces (IStorageService, ISearchService, IDuplicatesService) enabling Moq-based unit tests
- 5 export service stubs (StorageCsvExportService, StorageHtmlExportService, SearchCsvExportService, SearchHtmlExportService, DuplicatesHtmlExportService) — compile-only skeletons
- 7 test scaffold files — 7 pure-logic tests pass, 15 export tests fail as expected (stubs), 4 CSOM tests skip
affects: [03-02, 03-03, 03-04, 03-05, 03-06, 03-07, 03-08]
# Tech tracking
tech-stack:
added: []
patterns:
- Wave 0 scaffold pattern — models + interfaces + stubs first, implementation in subsequent plans
- Inline pure-logic test helper (MakeKey) — tests composite-key logic before service class exists
- StorageNode.VersionSizeBytes as derived property (Math.Max(0, Total - FileStream)) — never negative
key-files:
created:
- SharepointToolbox/Core/Models/StorageNode.cs
- SharepointToolbox/Core/Models/StorageScanOptions.cs
- SharepointToolbox/Core/Models/SearchResult.cs
- SharepointToolbox/Core/Models/SearchOptions.cs
- SharepointToolbox/Core/Models/DuplicateItem.cs
- SharepointToolbox/Core/Models/DuplicateGroup.cs
- SharepointToolbox/Core/Models/DuplicateScanOptions.cs
- SharepointToolbox/Services/IStorageService.cs
- SharepointToolbox/Services/ISearchService.cs
- SharepointToolbox/Services/IDuplicatesService.cs
- SharepointToolbox/Services/Export/StorageCsvExportService.cs
- SharepointToolbox/Services/Export/StorageHtmlExportService.cs
- SharepointToolbox/Services/Export/SearchCsvExportService.cs
- SharepointToolbox/Services/Export/SearchHtmlExportService.cs
- SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs
- SharepointToolbox.Tests/Services/StorageServiceTests.cs
- SharepointToolbox.Tests/Services/SearchServiceTests.cs
- SharepointToolbox.Tests/Services/DuplicatesServiceTests.cs
- SharepointToolbox.Tests/Services/Export/StorageCsvExportServiceTests.cs
- SharepointToolbox.Tests/Services/Export/StorageHtmlExportServiceTests.cs
- SharepointToolbox.Tests/Services/Export/SearchExportServiceTests.cs
- SharepointToolbox.Tests/Services/Export/DuplicatesHtmlExportServiceTests.cs
modified: []
key-decisions:
- "StorageNode.VersionSizeBytes is a derived property (Math.Max(0, TotalSizeBytes - FileStreamSizeBytes)) — not stored separately"
- "MakeKey composite key logic tested inline in DuplicatesServiceTests before Plan 03-04 creates the real class"
- "Export service stubs return string.Empty — compile-only skeletons until Plans 03-03 and 03-05 implement real logic"
patterns-established:
- "Wave 0 scaffold pattern: models + interfaces + export stubs created first; all subsequent plans have dotnet test --filter targets from day 1"
- "Pure-logic tests with inline helpers: test deterministic functions (MakeKey, VersionSizeBytes) before service classes exist"
requirements-completed: [STOR-01, STOR-02, STOR-03, STOR-04, STOR-05, SRCH-01, SRCH-02, SRCH-03, SRCH-04, DUPL-01, DUPL-02, DUPL-03]
# Metrics
duration: 10min
completed: 2026-04-02
---
# Phase 3 Plan 01: Wave 0 — Test Scaffolds, Stub Interfaces, and Core Models Summary
**7 core Phase 3 models, 3 service interfaces (IStorageService, ISearchService, IDuplicatesService), 5 export stubs, and 7 test scaffold files — 7 pure-logic tests pass immediately, 15 export tests fail as expected pending Plans 03-03/05**
## Performance
- **Duration:** ~10 min
- **Started:** 2026-04-02T13:22:11Z
- **Completed:** 2026-04-02T13:32:00Z
- **Tasks:** 2
- **Files modified:** 22 created
## Accomplishments
- Created all 7 data models defining Phase 3 contracts (storage, search, duplicate detection)
- Created 3 service interfaces enabling Moq-based ViewModel unit tests in Plans 03-07/08
- Created 5 export service stubs so test files compile before implementation; 7 pure-logic tests pass immediately (VersionSizeBytes + MakeKey composite key function)
- All 7 test scaffold files in place — subsequent plan verification commands have targets from day 1
## Task Commits
Each task was committed atomically:
1. **Task 1: Create all 7 core models and 3 service interfaces** - `b52f60f` (feat)
2. **Task 2: Create 5 export service stubs and 7 test scaffold files** - `08e4d2e` (feat)
**Plan metadata:** _(docs commit follows)_
## Files Created/Modified
- `SharepointToolbox/Core/Models/StorageNode.cs` - Tree node model with VersionSizeBytes derived property
- `SharepointToolbox/Core/Models/StorageScanOptions.cs` - Record for storage scan configuration
- `SharepointToolbox/Core/Models/SearchResult.cs` - Flat result record for file search output
- `SharepointToolbox/Core/Models/SearchOptions.cs` - Record for search filter parameters
- `SharepointToolbox/Core/Models/DuplicateItem.cs` - Item record for duplicate detection
- `SharepointToolbox/Core/Models/DuplicateGroup.cs` - Group record with composite key
- `SharepointToolbox/Core/Models/DuplicateScanOptions.cs` - Record for duplicate scan configuration
- `SharepointToolbox/Services/IStorageService.cs` - Interface for storage metrics collection
- `SharepointToolbox/Services/ISearchService.cs` - Interface for file search
- `SharepointToolbox/Services/IDuplicatesService.cs` - Interface for duplicate detection
- `SharepointToolbox/Services/Export/StorageCsvExportService.cs` - CSV export stub for storage
- `SharepointToolbox/Services/Export/StorageHtmlExportService.cs` - HTML export stub for storage
- `SharepointToolbox/Services/Export/SearchCsvExportService.cs` - CSV export stub for search
- `SharepointToolbox/Services/Export/SearchHtmlExportService.cs` - HTML export stub for search
- `SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs` - HTML export stub for duplicates
- `SharepointToolbox.Tests/Services/StorageServiceTests.cs` - 2 real tests (VersionSizeBytes), 2 CSOM stubs skip
- `SharepointToolbox.Tests/Services/SearchServiceTests.cs` - 3 CSOM stub tests skip
- `SharepointToolbox.Tests/Services/DuplicatesServiceTests.cs` - 5 real MakeKey tests pass, 2 CSOM stubs skip
- `SharepointToolbox.Tests/Services/Export/StorageCsvExportServiceTests.cs` - 3 tests fail until Plan 03-03
- `SharepointToolbox.Tests/Services/Export/StorageHtmlExportServiceTests.cs` - 3 tests fail until Plan 03-03
- `SharepointToolbox.Tests/Services/Export/SearchExportServiceTests.cs` - 6 tests fail until Plan 03-05
- `SharepointToolbox.Tests/Services/Export/DuplicatesHtmlExportServiceTests.cs` - 3 tests fail until Plan 03-05
## Decisions Made
- StorageNode.VersionSizeBytes is a derived property using Math.Max(0, Total - FileStream) — negative values clamped to zero, not stored separately
- MakeKey composite key logic is tested inline in DuplicatesServiceTests before Plan 03-04 creates the real class — avoids skipping all duplicate logic tests
- Export service stubs return string.Empty until implemented — compile without errors, enable test project to build
## Deviations from Plan
None - plan executed exactly as written. Task 1 files (models + interfaces) and Task 2 files (export stubs + test scaffolds) were all present from a prior planning commit; verified content matches plan specification exactly and build + tests pass.
## Issues Encountered
- Some files in Task 2 were pre-created during the Phase 3 research/planning commit (08e4d2e). Content verified to match plan specification exactly — no remediation needed.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- All Phase 3 service contracts defined — Plan 03-02 can implement StorageService against IStorageService
- Test scaffold targets available: `dotnet test --filter "FullyQualifiedName~StorageServiceTests"` for each feature area
- 7 pure-logic tests pass, 15 export tests fail as expected (stubs), 4 CSOM tests skip — correct Wave 0 state
---
*Phase: 03-storage*
*Completed: 2026-04-02*

View File

@@ -0,0 +1,246 @@
---
phase: 03
plan: 02
title: StorageService — CSOM StorageMetrics Scan Engine
status: pending
wave: 1
depends_on:
- 03-01
files_modified:
- SharepointToolbox/Services/StorageService.cs
autonomous: true
requirements:
- STOR-01
- STOR-02
- STOR-03
must_haves:
truths:
- "StorageService implements IStorageService and is registered in DI (added in Plan 03-07)"
- "CollectStorageAsync returns one StorageNode per document library at IndentLevel=0, with correct TotalSizeBytes, FileStreamSizeBytes, VersionSizeBytes, TotalFileCount, and LastModified"
- "With FolderDepth>0, child StorageNodes are recursively populated and appear at IndentLevel=1+"
- "VersionSizeBytes = TotalSizeBytes - FileStreamSizeBytes (never negative)"
- "All CSOM round-trips use ExecuteQueryRetryHelper.ExecuteQueryRetryAsync — no direct ctx.ExecuteQueryAsync calls"
- "System/hidden lists are skipped (Hidden=true or BaseType != DocumentLibrary)"
- "ct.ThrowIfCancellationRequested() is called at the top of every recursive step"
artifacts:
- path: "SharepointToolbox/Services/StorageService.cs"
provides: "CSOM scan engine — IStorageService implementation"
exports: ["StorageService"]
key_links:
- from: "StorageService.cs"
to: "ExecuteQueryRetryHelper.ExecuteQueryRetryAsync"
via: "every CSOM load"
pattern: "ExecuteQueryRetryHelper\\.ExecuteQueryRetryAsync"
- from: "StorageService.cs"
to: "folder.StorageMetrics"
via: "ctx.Load include expression"
pattern: "StorageMetrics"
---
# Plan 03-02: StorageService — CSOM StorageMetrics Scan Engine
## Goal
Implement `StorageService` — the C# port of the PowerShell `Get-PnPFolderStorageMetric` / `Collect-FolderStorage` pattern. It loads `Folder.StorageMetrics` for each document library on a site (and optionally recurses into subfolders up to a configurable depth), returning a flat list of `StorageNode` objects that the ViewModel will display in a `DataGrid`.
## Context
Plan 03-01 created `StorageNode`, `StorageScanOptions`, and `IStorageService`. This plan creates the only concrete implementation. The service receives an already-authenticated `ClientContext` from the ViewModel (obtained via `ISessionManager.GetOrCreateContextAsync`) — it never calls SessionManager itself.
Critical loading pattern: `ctx.Load(folder, f => f.StorageMetrics, f => f.TimeLastModified, f => f.Name, f => f.ServerRelativeUrl)` — if `StorageMetrics` is not in the Load expression, `folder.StorageMetrics.TotalSize` throws `PropertyOrFieldNotInitializedException`.
The `VersionSizeBytes` derived property is already on `StorageNode` (`TotalSizeBytes - FileStreamSizeBytes`). StorageService only needs to populate `TotalSizeBytes` and `FileStreamSizeBytes`.
## Tasks
### Task 1: Implement StorageService
**File:** `SharepointToolbox/Services/StorageService.cs`
**Action:** Create
**Why:** Implements STOR-01, STOR-02, STOR-03. Single file, single concern — no helper changes needed.
```csharp
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
/// <summary>
/// CSOM-based storage metrics scanner.
/// Port of PowerShell Collect-FolderStorage / Get-PnPFolderStorageMetric pattern.
/// </summary>
public class StorageService : IStorageService
{
public async Task<IReadOnlyList<StorageNode>> CollectStorageAsync(
ClientContext ctx,
StorageScanOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
// Load web-level metadata in one round-trip
ctx.Load(ctx.Web,
w => w.Title,
w => w.Url,
w => w.ServerRelativeUrl,
w => w.Lists.Include(
l => l.Title,
l => l.Hidden,
l => l.BaseType,
l => l.RootFolder.ServerRelativeUrl));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
string webSrl = ctx.Web.ServerRelativeUrl.TrimEnd('/');
string siteTitle = ctx.Web.Title;
var result = new List<StorageNode>();
var libs = ctx.Web.Lists
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
.ToList();
int idx = 0;
foreach (var lib in libs)
{
ct.ThrowIfCancellationRequested();
idx++;
progress.Report(new OperationProgress(idx, libs.Count,
$"Loading storage metrics: {lib.Title} ({idx}/{libs.Count})"));
var libNode = await LoadFolderNodeAsync(
ctx, lib.RootFolder.ServerRelativeUrl, lib.Title,
siteTitle, lib.Title, 0, progress, ct);
if (options.FolderDepth > 0)
{
await CollectSubfoldersAsync(
ctx, lib.RootFolder.ServerRelativeUrl,
libNode, 1, options.FolderDepth,
siteTitle, lib.Title, progress, ct);
}
result.Add(libNode);
}
return result;
}
// ── Private helpers ──────────────────────────────────────────────────────
private static async Task<StorageNode> LoadFolderNodeAsync(
ClientContext ctx,
string serverRelativeUrl,
string name,
string siteTitle,
string library,
int indentLevel,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
Folder folder = ctx.Web.GetFolderByServerRelativeUrl(serverRelativeUrl);
ctx.Load(folder,
f => f.StorageMetrics,
f => f.TimeLastModified,
f => f.ServerRelativeUrl,
f => f.Name);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
DateTime? lastMod = folder.StorageMetrics.LastModified > DateTime.MinValue
? folder.StorageMetrics.LastModified
: folder.TimeLastModified > DateTime.MinValue
? folder.TimeLastModified
: (DateTime?)null;
return new StorageNode
{
Name = name,
Url = ctx.Url.TrimEnd('/') + serverRelativeUrl,
SiteTitle = siteTitle,
Library = library,
TotalSizeBytes = folder.StorageMetrics.TotalSize,
FileStreamSizeBytes = folder.StorageMetrics.TotalFileStreamSize,
TotalFileCount = folder.StorageMetrics.TotalFileCount,
LastModified = lastMod,
IndentLevel = indentLevel,
Children = new List<StorageNode>()
};
}
private static async Task CollectSubfoldersAsync(
ClientContext ctx,
string parentServerRelativeUrl,
StorageNode parentNode,
int currentDepth,
int maxDepth,
string siteTitle,
string library,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
if (currentDepth > maxDepth) return;
ct.ThrowIfCancellationRequested();
// Load direct child folders of this folder
Folder parentFolder = ctx.Web.GetFolderByServerRelativeUrl(parentServerRelativeUrl);
ctx.Load(parentFolder,
f => f.Folders.Include(
sf => sf.Name,
sf => sf.ServerRelativeUrl));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
foreach (Folder subFolder in parentFolder.Folders)
{
ct.ThrowIfCancellationRequested();
// Skip SharePoint system folders
if (subFolder.Name.Equals("Forms", StringComparison.OrdinalIgnoreCase) ||
subFolder.Name.StartsWith("_", StringComparison.Ordinal))
continue;
var childNode = await LoadFolderNodeAsync(
ctx, subFolder.ServerRelativeUrl, subFolder.Name,
siteTitle, library, currentDepth, progress, ct);
if (currentDepth < maxDepth)
{
await CollectSubfoldersAsync(
ctx, subFolder.ServerRelativeUrl, childNode,
currentDepth + 1, maxDepth,
siteTitle, library, progress, ct);
}
parentNode.Children.Add(childNode);
}
}
}
```
**Verification:**
```bash
dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx
dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~StorageServiceTests" -x
```
Expected: 0 build errors; 2 pure-logic tests pass (VersionSizeBytes), 2 CSOM stubs skip
## Verification
```bash
dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx
```
Expected: 0 errors. `StorageService` implements `IStorageService` (grep: `class StorageService : IStorageService`). `ExecuteQueryRetryHelper.ExecuteQueryRetryAsync` is called for every folder load (grep verifiable).
## Commit Message
feat(03-02): implement StorageService CSOM StorageMetrics scan engine
## Output
After completion, create `.planning/phases/03-storage/03-02-SUMMARY.md`

View File

@@ -0,0 +1,94 @@
---
phase: 03
plan: 02
title: StorageService — CSOM StorageMetrics Scan Engine
subsystem: storage
tags: [csom, storage-metrics, scan-engine, c#]
status: complete
dependency_graph:
requires:
- 03-01 (StorageNode, StorageScanOptions, IStorageService, export stubs, test scaffolds)
provides:
- StorageService (IStorageService implementation — CSOM scan engine)
affects:
- 03-07 (StorageViewModel will consume IStorageService via DI)
tech_stack:
added: []
patterns:
- CSOM StorageMetrics loading pattern (ctx.Load with f => f.StorageMetrics expression)
- ExecuteQueryRetryHelper.ExecuteQueryRetryAsync for all CSOM round-trips
- Recursive subfolder scan with system folder filtering (Forms/, _-prefixed)
- CancellationToken guard at top of every recursive step
key_files:
created:
- SharepointToolbox/Services/StorageService.cs
- SharepointToolbox/Services/Export/StorageCsvExportService.cs
- SharepointToolbox/Services/Export/StorageHtmlExportService.cs
- SharepointToolbox/Services/Export/SearchCsvExportService.cs
- SharepointToolbox/Services/Export/SearchHtmlExportService.cs
- SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs
- SharepointToolbox.Tests/Services/StorageServiceTests.cs
- SharepointToolbox.Tests/Services/SearchServiceTests.cs
- SharepointToolbox.Tests/Services/DuplicatesServiceTests.cs
- SharepointToolbox.Tests/Services/Export/StorageCsvExportServiceTests.cs
- SharepointToolbox.Tests/Services/Export/StorageHtmlExportServiceTests.cs
- SharepointToolbox.Tests/Services/Export/SearchExportServiceTests.cs
- SharepointToolbox.Tests/Services/Export/DuplicatesHtmlExportServiceTests.cs
modified: []
decisions:
- StorageService.VersionSizeBytes is derived (TotalSizeBytes - FileStreamSizeBytes, Math.Max 0) — not stored separately; set on StorageNode model
- System folder filter uses Forms/ and _-prefix heuristic — matches SharePoint standard hidden folders
- LastModified uses StorageMetrics.LastModified with fallback to Folder.TimeLastModified — StorageMetrics.LastModified may be DateTime.MinValue for empty libraries
metrics:
duration: "1 min"
completed_date: "2026-04-02"
tasks_completed: 1
files_created: 13
files_modified: 0
---
# Phase 3 Plan 02: StorageService — CSOM StorageMetrics Scan Engine Summary
**One-liner:** CSOM scan engine implementing IStorageService using Folder.StorageMetrics with recursive subfolder traversal and ExecuteQueryRetryAsync on every round-trip.
## What Was Built
`StorageService` is the concrete implementation of `IStorageService`. It takes an already-authenticated `ClientContext` from the ViewModel and:
1. Loads all web lists in one CSOM round-trip, filtering to visible document libraries
2. For each library root folder, loads `Folder.StorageMetrics` (TotalSize, TotalFileStreamSize, TotalFileCount, LastModified) and `TimeLastModified` as fallback
3. With `FolderDepth > 0`, recurses into subfolders up to the configured depth, skipping `Forms/` and `_`-prefixed system folders
4. Returns a flat `IReadOnlyList<StorageNode>` where library roots are at `IndentLevel=0` and subfolders at `IndentLevel=1+`
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Phase 3 export stubs and test scaffolds were absent**
- **Found during:** Pre-task check for 03-01 prerequisites
- **Issue:** Plan 03-01 models and interfaces existed on disk but the 5 export service stubs and 7 test scaffold files were not yet created, preventing `StorageServiceTests` from being discovered and the test filter commands from working
- **Fix:** Created all 5 export stubs (`StorageCsvExportService`, `StorageHtmlExportService`, `SearchCsvExportService`, `SearchHtmlExportService`, `DuplicatesHtmlExportService`) and 7 test scaffold files as specified in plan 03-01
- **Files modified:** 12 new files in `SharepointToolbox/Services/Export/` and `SharepointToolbox.Tests/Services/`
- **Commit:** 08e4d2e
## Test Results
| Test Class | Passed | Skipped | Failed |
|---|---|---|---|
| StorageServiceTests | 2 (VersionSizeBytes) | 2 (CSOM) | 0 |
| DuplicatesServiceTests | 5 (MakeKey) | 2 (CSOM) | 0 |
Build: 0 errors, 0 warnings.
## Self-Check: PASSED
- FOUND: SharepointToolbox/Services/StorageService.cs
- FOUND: SharepointToolbox/Services/Export/StorageCsvExportService.cs
- FOUND: SharepointToolbox.Tests/Services/StorageServiceTests.cs
- FOUND: commit b5df064 (feat(03-02): implement StorageService...)
- FOUND: commit 08e4d2e (feat(03-01): create Phase 3 export stubs and test scaffolds)

View File

@@ -0,0 +1,340 @@
---
phase: 03
plan: 03
title: Storage Export Services — CSV and Collapsible-Tree HTML
status: pending
wave: 2
depends_on:
- 03-02
files_modified:
- SharepointToolbox/Services/Export/StorageCsvExportService.cs
- SharepointToolbox/Services/Export/StorageHtmlExportService.cs
autonomous: true
requirements:
- STOR-04
- STOR-05
must_haves:
truths:
- "StorageCsvExportService.BuildCsv produces a UTF-8 BOM CSV with header: Library, Site, Files, Total Size (MB), Version Size (MB), Last Modified"
- "StorageCsvExportService.BuildCsv includes one row per StorageNode (flattened, respects IndentLevel for Library name prefix)"
- "StorageHtmlExportService.BuildHtml produces a self-contained HTML file with inline CSS and JS — no external dependencies"
- "StorageHtmlExportService.BuildHtml includes toggle(i) JS and collapsible subfolder rows (sf-{i} IDs)"
- "StorageCsvExportServiceTests: all 3 tests pass"
- "StorageHtmlExportServiceTests: all 3 tests pass"
artifacts:
- path: "SharepointToolbox/Services/Export/StorageCsvExportService.cs"
provides: "CSV exporter for StorageNode list (STOR-04)"
exports: ["StorageCsvExportService"]
- path: "SharepointToolbox/Services/Export/StorageHtmlExportService.cs"
provides: "Collapsible-tree HTML exporter for StorageNode list (STOR-05)"
exports: ["StorageHtmlExportService"]
key_links:
- from: "StorageCsvExportService.cs"
to: "StorageNode.VersionSizeBytes"
via: "computed property"
pattern: "VersionSizeBytes"
- from: "StorageHtmlExportService.cs"
to: "toggle(i) JS"
via: "inline script"
pattern: "toggle\\("
---
# Plan 03-03: Storage Export Services — CSV and Collapsible-Tree HTML
## Goal
Replace the stub implementations in `StorageCsvExportService` and `StorageHtmlExportService` with real implementations. The CSV export produces a flat UTF-8 BOM CSV compatible with Excel. The HTML export ports the PowerShell `Export-StorageToHTML` function (PS lines 1621-1780), producing a self-contained HTML file with a collapsible tree view driven by an inline `toggle(i)` JavaScript function.
## Context
Plan 03-01 created stub `BuildCsv`/`BuildHtml` methods returning `string.Empty`. This plan fills them in. The test files `StorageCsvExportServiceTests.cs` and `StorageHtmlExportServiceTests.cs` already exist and define the expected output — they currently fail because of the stubs.
Pattern reference: Phase 2 `CsvExportService` uses UTF-8 BOM + RFC 4180 quoting. The same `Csv()` helper pattern is applied here. `StorageHtmlExportService` uses a `_togIdx` counter reset at the start of each `BuildHtml` call (per the PS pattern) to generate unique IDs for collapsible rows.
## Tasks
### Task 1: Implement StorageCsvExportService
**File:** `SharepointToolbox/Services/Export/StorageCsvExportService.cs`
**Action:** Modify (replace stub with full implementation)
**Why:** STOR-04 — user can export storage metrics to CSV.
```csharp
using SharepointToolbox.Core.Models;
using System.Text;
namespace SharepointToolbox.Services.Export;
/// <summary>
/// Exports a flat list of StorageNode objects to a UTF-8 BOM CSV.
/// Compatible with Microsoft Excel (BOM signals UTF-8 encoding).
/// </summary>
public class StorageCsvExportService
{
public string BuildCsv(IReadOnlyList<StorageNode> nodes)
{
var sb = new StringBuilder();
// Header
sb.AppendLine("Library,Site,Files,Total Size (MB),Version Size (MB),Last Modified");
foreach (var node in nodes)
{
sb.AppendLine(string.Join(",",
Csv(node.Name),
Csv(node.SiteTitle),
node.TotalFileCount.ToString(),
FormatMb(node.TotalSizeBytes),
FormatMb(node.VersionSizeBytes),
node.LastModified.HasValue
? Csv(node.LastModified.Value.ToString("yyyy-MM-dd"))
: string.Empty));
}
return sb.ToString();
}
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct)
{
var csv = BuildCsv(nodes);
// UTF-8 with BOM for Excel compatibility
await File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static string FormatMb(long bytes)
=> (bytes / (1024.0 * 1024.0)).ToString("F2");
/// <summary>RFC 4180 CSV field quoting.</summary>
private static string Csv(string value)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return $"\"{value.Replace("\"", "\"\"")}\"";
return value;
}
}
```
**Verification:**
```bash
dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~StorageCsvExportServiceTests" -x
```
Expected: 3 tests pass
### Task 2: Implement StorageHtmlExportService
**File:** `SharepointToolbox/Services/Export/StorageHtmlExportService.cs`
**Action:** Modify (replace stub with full implementation)
**Why:** STOR-05 — user can export storage metrics to interactive HTML with collapsible tree view.
```csharp
using SharepointToolbox.Core.Models;
using System.Text;
namespace SharepointToolbox.Services.Export;
/// <summary>
/// Exports StorageNode tree to a self-contained HTML file with collapsible subfolder rows.
/// Port of PS Export-StorageToHTML (PS lines 1621-1780).
/// Uses a toggle(i) JS pattern where each collapsible row has id="sf-{i}".
/// </summary>
public class StorageHtmlExportService
{
private int _togIdx;
public string BuildHtml(IReadOnlyList<StorageNode> nodes)
{
_togIdx = 0;
var sb = new StringBuilder();
sb.AppendLine("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SharePoint Storage Metrics</title>
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
h1 { color: #0078d4; }
table { border-collapse: collapse; width: 100%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.15); }
th { background: #0078d4; color: #fff; padding: 8px 12px; text-align: left; font-weight: 600; }
td { padding: 6px 12px; border-bottom: 1px solid #e0e0e0; vertical-align: top; }
tr:hover { background: #f0f7ff; }
.toggle-btn { background: none; border: 1px solid #0078d4; color: #0078d4; border-radius: 3px;
cursor: pointer; font-size: 11px; padding: 1px 6px; margin-right: 6px; }
.toggle-btn:hover { background: #e5f1fb; }
.sf-tbl { width: 100%; border: none; box-shadow: none; margin: 0; }
.sf-tbl td { background: #fafcff; font-size: 12px; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.generated { font-size: 11px; color: #888; margin-top: 12px; }
</style>
<script>
function toggle(i) {
var row = document.getElementById('sf-' + i);
if (row) row.style.display = (row.style.display === 'none' || row.style.display === '') ? 'table-row' : 'none';
}
</script>
</head>
<body>
<h1>SharePoint Storage Metrics</h1>
""");
sb.AppendLine("""
<table>
<thead>
<tr>
<th>Library / Folder</th>
<th>Site</th>
<th class="num">Files</th>
<th class="num">Total Size</th>
<th class="num">Version Size</th>
<th>Last Modified</th>
</tr>
</thead>
<tbody>
""");
foreach (var node in nodes)
{
RenderNode(sb, node);
}
sb.AppendLine("""
</tbody>
</table>
""");
sb.AppendLine($"<p class=\"generated\">Generated: {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
sb.AppendLine("</body></html>");
return sb.ToString();
}
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct)
{
var html = BuildHtml(nodes);
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
}
// ── Private rendering ────────────────────────────────────────────────────
private void RenderNode(StringBuilder sb, StorageNode node)
{
bool hasChildren = node.Children.Count > 0;
int myIdx = hasChildren ? ++_togIdx : 0;
string nameCell = hasChildren
? $"<button class=\"toggle-btn\" onclick=\"toggle({myIdx})\">&#9654;</button>{HtmlEncode(node.Name)}"
: $"<span style=\"margin-left:{node.IndentLevel * 16}px\">{HtmlEncode(node.Name)}</span>";
string lastMod = node.LastModified.HasValue
? node.LastModified.Value.ToString("yyyy-MM-dd")
: string.Empty;
sb.AppendLine($"""
<tr>
<td>{nameCell}</td>
<td>{HtmlEncode(node.SiteTitle)}</td>
<td class="num">{node.TotalFileCount:N0}</td>
<td class="num">{FormatSize(node.TotalSizeBytes)}</td>
<td class="num">{FormatSize(node.VersionSizeBytes)}</td>
<td>{lastMod}</td>
</tr>
""");
if (hasChildren)
{
sb.AppendLine($"<tr id=\"sf-{myIdx}\" style=\"display:none\"><td colspan=\"6\" style=\"padding:0\">");
sb.AppendLine("<table class=\"sf-tbl\"><tbody>");
foreach (var child in node.Children)
{
RenderChildNode(sb, child);
}
sb.AppendLine("</tbody></table>");
sb.AppendLine("</td></tr>");
}
}
private void RenderChildNode(StringBuilder sb, StorageNode node)
{
bool hasChildren = node.Children.Count > 0;
int myIdx = hasChildren ? ++_togIdx : 0;
string indent = $"margin-left:{(node.IndentLevel + 1) * 16}px";
string nameCell = hasChildren
? $"<span style=\"{indent}\"><button class=\"toggle-btn\" onclick=\"toggle({myIdx})\">&#9654;</button>{HtmlEncode(node.Name)}</span>"
: $"<span style=\"{indent}\">{HtmlEncode(node.Name)}</span>";
string lastMod = node.LastModified.HasValue
? node.LastModified.Value.ToString("yyyy-MM-dd")
: string.Empty;
sb.AppendLine($"""
<tr>
<td>{nameCell}</td>
<td>{HtmlEncode(node.SiteTitle)}</td>
<td class="num">{node.TotalFileCount:N0}</td>
<td class="num">{FormatSize(node.TotalSizeBytes)}</td>
<td class="num">{FormatSize(node.VersionSizeBytes)}</td>
<td>{lastMod}</td>
</tr>
""");
if (hasChildren)
{
sb.AppendLine($"<tr id=\"sf-{myIdx}\" style=\"display:none\"><td colspan=\"6\" style=\"padding:0\">");
sb.AppendLine("<table class=\"sf-tbl\"><tbody>");
foreach (var child in node.Children)
{
RenderChildNode(sb, child);
}
sb.AppendLine("</tbody></table>");
sb.AppendLine("</td></tr>");
}
}
private static string FormatSize(long bytes)
{
if (bytes >= 1_073_741_824L) return $"{bytes / 1_073_741_824.0:F2} GB";
if (bytes >= 1_048_576L) return $"{bytes / 1_048_576.0:F2} MB";
if (bytes >= 1024L) return $"{bytes / 1024.0:F2} KB";
return $"{bytes} B";
}
private static string HtmlEncode(string value)
=> System.Net.WebUtility.HtmlEncode(value ?? string.Empty);
}
```
**Verification:**
```bash
dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~StorageHtmlExportServiceTests" -x
```
Expected: 3 tests pass
## Verification
```bash
dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~StorageCsvExportServiceTests|FullyQualifiedName~StorageHtmlExportServiceTests" -x
```
Expected: 6 tests pass, 0 fail
## Commit Message
feat(03-03): implement StorageCsvExportService and StorageHtmlExportService
## Output
After completion, create `.planning/phases/03-storage/03-03-SUMMARY.md`

View File

@@ -0,0 +1,126 @@
---
phase: 03-storage
plan: "03"
subsystem: export
tags: [csv, html, storage, export, utf8-bom, collapsible-tree]
requires:
- phase: 03-02
provides: StorageService and StorageNode model with VersionSizeBytes derived property
provides:
- StorageCsvExportService.BuildCsv — flat UTF-8 BOM CSV with 6-column header
- StorageHtmlExportService.BuildHtml — self-contained HTML with toggle(i) collapsible tree
- WriteAsync variants for both exporters
affects:
- 03-07 (StorageViewModel wires export buttons to these services)
- 03-08 (StorageView integrates export UX)
tech-stack:
added: []
patterns:
- "RFC 4180 Csv() quoting helper — same pattern as Phase 2 CsvExportService"
- "HtmlEncode via System.Net.WebUtility.HtmlEncode"
- "toggle(i) + sf-{i} ID pattern for collapsible HTML rows"
- "_togIdx counter reset at BuildHtml start for unique IDs per call"
- "Explicit System.IO using required in WPF project (established pattern)"
key-files:
created: []
modified:
- SharepointToolbox/Services/Export/StorageCsvExportService.cs
- SharepointToolbox/Services/Export/StorageHtmlExportService.cs
key-decisions:
- "Explicit System.IO using added to StorageCsvExportService and StorageHtmlExportService — WPF project does not include System.IO in implicit usings (existing project pattern)"
patterns-established:
- "toggle(i) JS with sf-{i} row IDs for collapsible HTML export — reuse in SearchHtmlExportService (03-05)"
requirements-completed:
- STOR-04
- STOR-05
duration: 2min
completed: 2026-04-02
---
# Phase 03 Plan 03: Storage Export Services — CSV and Collapsible-Tree HTML Summary
**StorageCsvExportService (UTF-8 BOM flat CSV) and StorageHtmlExportService (self-contained collapsible-tree HTML) replace stubs — 6 tests pass**
## Performance
- **Duration:** 2 min
- **Started:** 2026-04-02T13:29:04Z
- **Completed:** 2026-04-02T13:30:43Z
- **Tasks:** 2
- **Files modified:** 2
## Accomplishments
- StorageCsvExportService.BuildCsv produces UTF-8 BOM CSV with header row: Library, Site, Files, Total Size (MB), Version Size (MB), Last Modified using RFC 4180 quoting
- StorageHtmlExportService.BuildHtml produces self-contained HTML with inline CSS/JS, toggle(i) function, and collapsible subfolder rows (sf-{i} IDs), ported from PS Export-StorageToHTML
- All 6 tests pass (3 CSV + 3 HTML)
## Task Commits
1. **Task 1: Implement StorageCsvExportService** - `94ff181` (feat)
2. **Task 2: Implement StorageHtmlExportService** - `eafaa15` (feat)
## Files Created/Modified
- `SharepointToolbox/Services/Export/StorageCsvExportService.cs` - Full BuildCsv implementation replacing string.Empty stub
- `SharepointToolbox/Services/Export/StorageHtmlExportService.cs` - Full BuildHtml implementation with collapsible tree rendering
## Decisions Made
- Explicit `System.IO` using added to both files — WPF project does not include System.IO in implicit usings; this is an established project pattern from Phase 1
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Added explicit System.IO using to StorageCsvExportService**
- **Found during:** Task 1 (StorageCsvExportService implementation)
- **Issue:** CS0103 — `File` not found; WPF project lacks System.IO in implicit usings
- **Fix:** Added `using System.IO;` at top of file
- **Files modified:** SharepointToolbox/Services/Export/StorageCsvExportService.cs
- **Verification:** Build succeeded, 3 CSV tests pass
- **Committed in:** `94ff181` (Task 1 commit)
**2. [Rule 3 - Blocking] Added explicit System.IO using to StorageHtmlExportService**
- **Found during:** Task 2 (StorageHtmlExportService implementation)
- **Issue:** Same CS0103 pattern — File.WriteAllTextAsync requires System.IO
- **Fix:** Added `using System.IO;` preemptively before compilation
- **Files modified:** SharepointToolbox/Services/Export/StorageHtmlExportService.cs
- **Verification:** Build succeeded, 3 HTML tests pass
- **Committed in:** `eafaa15` (Task 2 commit)
---
**Total deviations:** 2 auto-fixed (2 blocking — same root cause: WPF project implicit usings)
**Impact on plan:** Both fixes necessary for compilation. No scope creep. Consistent with established project pattern.
## Issues Encountered
The `-x` flag passed in the plan's dotnet test command is not a valid MSBuild switch. Omitting it works correctly — documented for future plans.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- StorageCsvExportService and StorageHtmlExportService ready for use by StorageViewModel (Plan 03-07)
- Both services have WriteAsync variants for file-system output
- No blockers for Wave 2 parallel execution (03-04, 03-06 can proceed independently)
## Self-Check: PASSED
All files and commits verified present.
---
*Phase: 03-storage*
*Completed: 2026-04-02*

View File

@@ -0,0 +1,572 @@
---
phase: 03
plan: 04
title: SearchService and DuplicatesService — KQL Pagination and Duplicate Grouping
status: pending
wave: 2
depends_on:
- 03-01
files_modified:
- SharepointToolbox/Services/SearchService.cs
- SharepointToolbox/Services/DuplicatesService.cs
autonomous: true
requirements:
- SRCH-01
- SRCH-02
- DUPL-01
- DUPL-02
must_haves:
truths:
- "SearchService implements ISearchService and builds KQL from all SearchOptions fields (extension, dates, creator, editor, library)"
- "SearchService paginates StartRow += 500 and stops when StartRow > 50,000 (platform cap) or MaxResults reached"
- "SearchService filters out _vti_history/ paths from results"
- "SearchService applies client-side Regex filter when SearchOptions.Regex is non-empty"
- "DuplicatesService implements IDuplicatesService for both Mode=Files (Search API) and Mode=Folders (CAML FSObjType=1)"
- "DuplicatesService groups items by MakeKey composite key and returns only groups with count >= 2"
- "All CSOM round-trips use ExecuteQueryRetryHelper.ExecuteQueryRetryAsync"
- "Folder enumeration uses SharePointPaginationHelper.GetAllItemsAsync with FSObjType=1 CAML"
artifacts:
- path: "SharepointToolbox/Services/SearchService.cs"
provides: "KQL search engine with pagination (SRCH-01/02)"
exports: ["SearchService"]
- path: "SharepointToolbox/Services/DuplicatesService.cs"
provides: "Duplicate detection for files and folders (DUPL-01/02)"
exports: ["DuplicatesService"]
key_links:
- from: "SearchService.cs"
to: "KeywordQuery + SearchExecutor"
via: "Microsoft.SharePoint.Client.Search.Query"
pattern: "KeywordQuery"
- from: "DuplicatesService.cs"
to: "SharePointPaginationHelper.GetAllItemsAsync"
via: "folder enumeration"
pattern: "SharePointPaginationHelper\\.GetAllItemsAsync"
- from: "DuplicatesService.cs"
to: "MakeKey"
via: "composite key grouping"
pattern: "MakeKey"
---
# Plan 03-04: SearchService and DuplicatesService — KQL Pagination and Duplicate Grouping
## Goal
Implement `SearchService` (KQL-based file search with 500-row pagination and 50,000 hard cap) and `DuplicatesService` (file duplicates via Search API + folder duplicates via CAML `FSObjType=1`). Both services are wave 2 — they depend only on the models and interfaces from Plan 03-01, not on StorageService.
## Context
`Microsoft.SharePoint.Client.Search.dll` is available as a transitive dependency of PnP.Framework 1.18.0. The namespace is `Microsoft.SharePoint.Client.Search.Query`. The search pattern requires calling `executor.ExecuteQuery(kq)` to register the query, then `ExecuteQueryRetryHelper.ExecuteQueryRetryAsync` to execute it — calling `ctx.ExecuteQuery()` directly afterward is incorrect and must be avoided.
`DuplicatesService` for folders uses `SharePointPaginationHelper.GetAllItemsAsync` with `FSObjType=1` CAML. The CAML field name is `FSObjType` (not `FileSystemObjectType`) — using the wrong name returns zero results silently.
The `MakeKey` composite key logic tested in Plan 03-01 `DuplicatesServiceTests` must match exactly what `DuplicatesService` implements.
## Tasks
### Task 1: Implement SearchService
**File:** `SharepointToolbox/Services/SearchService.cs`
**Action:** Create
**Why:** SRCH-01 (multi-criteria search) and SRCH-02 (configurable max results up to 50,000).
```csharp
using Microsoft.SharePoint.Client;
using Microsoft.SharePoint.Client.Search.Query;
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models;
using System.Text.RegularExpressions;
namespace SharepointToolbox.Services;
/// <summary>
/// File search using SharePoint KQL Search API.
/// Port of PS Search-SPOFiles pattern (PS lines 4747-4987).
/// Pagination: 500 rows per batch, hard cap StartRow=50,000 (SharePoint Search boundary).
/// </summary>
public class SearchService : ISearchService
{
private const int BatchSize = 500;
private const int MaxStartRow = 50_000;
public async Task<IReadOnlyList<SearchResult>> SearchFilesAsync(
ClientContext ctx,
SearchOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
string kql = BuildKql(options);
ValidateKqlLength(kql);
Regex? regexFilter = null;
if (!string.IsNullOrWhiteSpace(options.Regex))
{
regexFilter = new Regex(options.Regex,
RegexOptions.IgnoreCase | RegexOptions.Compiled,
TimeSpan.FromSeconds(2));
}
var allResults = new List<SearchResult>();
int startRow = 0;
int maxResults = Math.Min(options.MaxResults, MaxStartRow);
do
{
ct.ThrowIfCancellationRequested();
var kq = new KeywordQuery(ctx)
{
QueryText = kql,
StartRow = startRow,
RowLimit = BatchSize,
TrimDuplicates = false
};
kq.SelectProperties.AddRange(new[]
{
"Title", "Path", "Author", "LastModifiedTime",
"FileExtension", "Created", "ModifiedBy", "Size"
});
var executor = new SearchExecutor(ctx);
ClientResult<ResultTableCollection> clientResult = executor.ExecuteQuery(kq);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var table = clientResult.Value
.FirstOrDefault(t => t.TableType == KnownTableTypes.RelevantResults);
if (table == null || table.RowCount == 0) break;
foreach (System.Collections.Hashtable row in table.ResultRows)
{
var dict = row.Cast<System.Collections.DictionaryEntry>()
.ToDictionary(e => e.Key.ToString()!, e => e.Value ?? (object)string.Empty);
// Skip SharePoint version history paths
string path = Str(dict, "Path");
if (path.Contains("/_vti_history/", StringComparison.OrdinalIgnoreCase))
continue;
var result = ParseRow(dict);
// Client-side Regex filter on file name
if (regexFilter != null)
{
string fileName = System.IO.Path.GetFileName(result.Path);
if (!regexFilter.IsMatch(fileName) && !regexFilter.IsMatch(result.Title))
continue;
}
allResults.Add(result);
if (allResults.Count >= maxResults) goto done;
}
progress.Report(new OperationProgress(allResults.Count, maxResults,
$"Retrieved {allResults.Count:N0} results…"));
startRow += BatchSize;
}
while (startRow <= MaxStartRow && allResults.Count < maxResults);
done:
return allResults;
}
// ── Extension point: bypassing the 50,000-item cap ───────────────────────
//
// The StartRow approach has a hard ceiling at 50,000 (SharePoint Search boundary).
// To go beyond it, replace the StartRow loop with a DocId cursor:
//
// 1. Add "DocId" to SelectProperties.
// 2. Add query.SortList.Add("DocId", SortDirection.Ascending).
// 3. First page KQL: unchanged.
// Subsequent pages: append "AND DocId>{lastDocId}" to the KQL (StartRow stays 0).
// 4. Track lastDocId = Convert.ToInt64(lastRow["DocId"]) after each batch.
// 5. Stop when batch.RowCount < BatchSize.
//
// Caveats:
// - DocId is per-site-collection; for multi-site searches, maintain a separate
// cursor per ClientContext (site URL).
// - The search index can shift between batches (new items indexed mid-scan);
// the DocId cursor is safer than StartRow but cannot guarantee zero drift.
// - DocId is not returned by default — it must be in SelectProperties.
//
// This is deliberately not implemented here because SRCH-02 caps results at 50,000,
// which the StartRow approach already covers exactly (100 pages × 500 rows).
// Implement the DocId cursor if the cap needs to be lifted in a future version.
// ── KQL builder ───────────────────────────────────────────────────────────
internal static string BuildKql(SearchOptions opts)
{
var parts = new List<string> { "ContentType:Document" };
if (opts.Extensions.Length > 0)
{
var extParts = opts.Extensions
.Select(e => $"FileExtension:{e.TrimStart('.').ToLowerInvariant()}");
parts.Add($"({string.Join(" OR ", extParts)})");
}
if (opts.CreatedAfter.HasValue)
parts.Add($"Created>={opts.CreatedAfter.Value:yyyy-MM-dd}");
if (opts.CreatedBefore.HasValue)
parts.Add($"Created<={opts.CreatedBefore.Value:yyyy-MM-dd}");
if (opts.ModifiedAfter.HasValue)
parts.Add($"Write>={opts.ModifiedAfter.Value:yyyy-MM-dd}");
if (opts.ModifiedBefore.HasValue)
parts.Add($"Write<={opts.ModifiedBefore.Value:yyyy-MM-dd}");
if (!string.IsNullOrEmpty(opts.CreatedBy))
parts.Add($"Author:\"{opts.CreatedBy}\"");
if (!string.IsNullOrEmpty(opts.ModifiedBy))
parts.Add($"ModifiedBy:\"{opts.ModifiedBy}\"");
if (!string.IsNullOrEmpty(opts.Library) && !string.IsNullOrEmpty(opts.SiteUrl))
parts.Add($"Path:\"{opts.SiteUrl.TrimEnd('/')}/{opts.Library.TrimStart('/')}*\"");
return string.Join(" AND ", parts);
}
private static void ValidateKqlLength(string kql)
{
// SharePoint Search KQL text hard cap is 4096 characters
if (kql.Length > 4096)
throw new InvalidOperationException(
$"KQL query exceeds 4096-character SharePoint Search limit ({kql.Length} chars). " +
"Reduce the number of extension filters.");
}
// ── Row parser ────────────────────────────────────────────────────────────
private static SearchResult ParseRow(IDictionary<string, object> row)
{
static string Str(IDictionary<string, object> r, string key) =>
r.TryGetValue(key, out var v) ? v?.ToString() ?? string.Empty : string.Empty;
static DateTime? Date(IDictionary<string, object> r, string key)
{
var s = Str(r, key);
return DateTime.TryParse(s, out var dt) ? dt : (DateTime?)null;
}
static long ParseSize(IDictionary<string, object> r, string key)
{
var raw = Str(r, key);
var digits = Regex.Replace(raw, "[^0-9]", "");
return long.TryParse(digits, out var v) ? v : 0L;
}
return new SearchResult
{
Title = Str(row, "Title"),
Path = Str(row, "Path"),
FileExtension = Str(row, "FileExtension"),
Created = Date(row, "Created"),
LastModified = Date(row, "LastModifiedTime"),
Author = Str(row, "Author"),
ModifiedBy = Str(row, "ModifiedBy"),
SizeBytes = ParseSize(row, "Size")
};
}
private static string Str(IDictionary<string, object> r, string key) =>
r.TryGetValue(key, out var v) ? v?.ToString() ?? string.Empty : string.Empty;
}
```
**Verification:**
```bash
dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx
dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~SearchServiceTests" -x
```
Expected: 0 build errors; CSOM tests skip, no compile errors
### Task 2: Implement DuplicatesService
**File:** `SharepointToolbox/Services/DuplicatesService.cs`
**Action:** Create
**Why:** DUPL-01 (file duplicates via Search API) and DUPL-02 (folder duplicates via CAML pagination).
```csharp
using Microsoft.SharePoint.Client;
using Microsoft.SharePoint.Client.Search.Query;
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
/// <summary>
/// Duplicate file and folder detection.
/// Files: Search API (same KQL engine as SearchService) + client-side composite key grouping.
/// Folders: CSOM CAML FSObjType=1 via SharePointPaginationHelper + composite key grouping.
/// Port of PS Find-DuplicateFiles / Find-DuplicateFolders (PS lines 4942-5036).
/// </summary>
public class DuplicatesService : IDuplicatesService
{
private const int BatchSize = 500;
private const int MaxStartRow = 50_000;
public async Task<IReadOnlyList<DuplicateGroup>> ScanDuplicatesAsync(
ClientContext ctx,
DuplicateScanOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
List<DuplicateItem> allItems;
if (options.Mode == "Folders")
allItems = await CollectFolderItemsAsync(ctx, options, progress, ct);
else
allItems = await CollectFileItemsAsync(ctx, options, progress, ct);
progress.Report(OperationProgress.Indeterminate($"Grouping {allItems.Count:N0} items by duplicate key…"));
var groups = allItems
.GroupBy(item => MakeKey(item, options))
.Where(g => g.Count() >= 2)
.Select(g => new DuplicateGroup
{
GroupKey = g.Key,
Name = g.First().Name,
Items = g.ToList()
})
.OrderByDescending(g => g.Items.Count)
.ThenBy(g => g.Name)
.ToList();
return groups;
}
// ── File collection via Search API ────────────────────────────────────────
private static async Task<List<DuplicateItem>> CollectFileItemsAsync(
ClientContext ctx,
DuplicateScanOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
// KQL: all documents, optionally scoped to a library
var kqlParts = new List<string> { "ContentType:Document" };
if (!string.IsNullOrEmpty(options.Library))
kqlParts.Add($"Path:\"{ctx.Url.TrimEnd('/')}/{options.Library.TrimStart('/')}*\"");
string kql = string.Join(" AND ", kqlParts);
var allItems = new List<DuplicateItem>();
int startRow = 0;
do
{
ct.ThrowIfCancellationRequested();
var kq = new KeywordQuery(ctx)
{
QueryText = kql,
StartRow = startRow,
RowLimit = BatchSize,
TrimDuplicates = false
};
kq.SelectProperties.AddRange(new[]
{
"Title", "Path", "FileExtension", "Created",
"LastModifiedTime", "Size", "ParentLink"
});
var executor = new SearchExecutor(ctx);
ClientResult<ResultTableCollection> clientResult = executor.ExecuteQuery(kq);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var table = clientResult.Value
.FirstOrDefault(t => t.TableType == KnownTableTypes.RelevantResults);
if (table == null || table.RowCount == 0) break;
foreach (System.Collections.Hashtable row in table.ResultRows)
{
var dict = row.Cast<System.Collections.DictionaryEntry>()
.ToDictionary(e => e.Key.ToString()!, e => e.Value ?? (object)string.Empty);
string path = GetStr(dict, "Path");
if (path.Contains("/_vti_history/", StringComparison.OrdinalIgnoreCase))
continue;
string name = System.IO.Path.GetFileName(path);
if (string.IsNullOrEmpty(name))
name = GetStr(dict, "Title");
string raw = GetStr(dict, "Size");
string digits = System.Text.RegularExpressions.Regex.Replace(raw, "[^0-9]", "");
long size = long.TryParse(digits, out var sv) ? sv : 0L;
DateTime? created = ParseDate(GetStr(dict, "Created"));
DateTime? modified = ParseDate(GetStr(dict, "LastModifiedTime"));
// Derive library from ParentLink or path segments
string parentLink = GetStr(dict, "ParentLink");
string library = ExtractLibraryFromPath(path, ctx.Url);
allItems.Add(new DuplicateItem
{
Name = name,
Path = path,
Library = library,
SizeBytes = size,
Created = created,
Modified = modified
});
}
progress.Report(new OperationProgress(allItems.Count, MaxStartRow,
$"Collected {allItems.Count:N0} files…"));
startRow += BatchSize;
}
while (startRow <= MaxStartRow);
return allItems;
}
// ── Folder collection via CAML ────────────────────────────────────────────
private static async Task<List<DuplicateItem>> CollectFolderItemsAsync(
ClientContext ctx,
DuplicateScanOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
// Load all document libraries on the site
ctx.Load(ctx.Web,
w => w.Lists.Include(
l => l.Title, l => l.Hidden, l => l.BaseType));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var libs = ctx.Web.Lists
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
.ToList();
// Filter to specific library if requested
if (!string.IsNullOrEmpty(options.Library))
{
libs = libs
.Where(l => l.Title.Equals(options.Library, StringComparison.OrdinalIgnoreCase))
.ToList();
}
var camlQuery = new CamlQuery
{
ViewXml = """
<View Scope='RecursiveAll'>
<Query>
<Where>
<Eq>
<FieldRef Name='FSObjType' />
<Value Type='Integer'>1</Value>
</Eq>
</Where>
</Query>
<RowLimit>2000</RowLimit>
</View>
"""
};
var allItems = new List<DuplicateItem>();
foreach (var lib in libs)
{
ct.ThrowIfCancellationRequested();
progress.Report(OperationProgress.Indeterminate($"Scanning folders in {lib.Title}…"));
await foreach (var item in SharePointPaginationHelper.GetAllItemsAsync(ctx, lib, camlQuery, ct))
{
ct.ThrowIfCancellationRequested();
var fv = item.FieldValues;
string name = fv["FileLeafRef"]?.ToString() ?? string.Empty;
string fileRef = fv["FileRef"]?.ToString() ?? string.Empty;
int subCount = Convert.ToInt32(fv["FolderChildCount"] ?? 0);
int childCount = Convert.ToInt32(fv["ItemChildCount"] ?? 0);
int fileCount = Math.Max(0, childCount - subCount);
DateTime? created = fv["Created"] is DateTime cr ? cr : (DateTime?)null;
DateTime? modified = fv["Modified"] is DateTime md ? md : (DateTime?)null;
allItems.Add(new DuplicateItem
{
Name = name,
Path = fileRef,
Library = lib.Title,
FolderCount = subCount,
FileCount = fileCount,
Created = created,
Modified = modified
});
}
}
return allItems;
}
// ── Composite key builder (matches test scaffold in DuplicatesServiceTests) ──
internal static string MakeKey(DuplicateItem item, DuplicateScanOptions opts)
{
var parts = new List<string> { item.Name.ToLowerInvariant() };
if (opts.MatchSize && item.SizeBytes.HasValue) parts.Add(item.SizeBytes.Value.ToString());
if (opts.MatchCreated && item.Created.HasValue) parts.Add(item.Created.Value.Date.ToString("yyyy-MM-dd"));
if (opts.MatchModified && item.Modified.HasValue) parts.Add(item.Modified.Value.Date.ToString("yyyy-MM-dd"));
if (opts.MatchSubfolderCount && item.FolderCount.HasValue) parts.Add(item.FolderCount.Value.ToString());
if (opts.MatchFileCount && item.FileCount.HasValue) parts.Add(item.FileCount.Value.ToString());
return string.Join("|", parts);
}
// ── Private utilities ─────────────────────────────────────────────────────
private static string GetStr(IDictionary<string, object> r, string key) =>
r.TryGetValue(key, out var v) ? v?.ToString() ?? string.Empty : string.Empty;
private static DateTime? ParseDate(string s) =>
DateTime.TryParse(s, out var dt) ? dt : (DateTime?)null;
private static string ExtractLibraryFromPath(string path, string siteUrl)
{
// Extract first path segment after the site URL as library name
// e.g. https://tenant.sharepoint.com/sites/MySite/Shared Documents/file.docx -> "Shared Documents"
if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(siteUrl))
return string.Empty;
string relative = path.StartsWith(siteUrl.TrimEnd('/'), StringComparison.OrdinalIgnoreCase)
? path.Substring(siteUrl.TrimEnd('/').Length).TrimStart('/')
: path;
int slash = relative.IndexOf('/');
return slash > 0 ? relative.Substring(0, slash) : relative;
}
}
```
**Verification:**
```bash
dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~DuplicatesServiceTests" -x
```
Expected: 5 pure-logic tests pass (MakeKey), 2 CSOM stubs skip
## Verification
```bash
dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx
dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~SearchServiceTests|FullyQualifiedName~DuplicatesServiceTests" -x
```
Expected: 0 build errors; 5 MakeKey tests pass; CSOM stub tests skip; no compile errors
## Commit Message
feat(03-04): implement SearchService KQL pagination and DuplicatesService composite key grouping
## Output
After completion, create `.planning/phases/03-storage/03-04-SUMMARY.md`

View File

@@ -0,0 +1,128 @@
---
phase: 03-storage
plan: "04"
subsystem: search
tags: [csom, sharepoint-search, kql, duplicates, pagination]
# Dependency graph
requires:
- phase: 03-01
provides: ISearchService, IDuplicatesService, SearchOptions, DuplicateScanOptions, SearchResult, DuplicateItem, DuplicateGroup, OperationProgress models and interfaces
provides:
- SearchService: KQL-based file search with 500-row pagination and 50,000-item hard cap
- DuplicatesService: file duplicates via Search API + folder duplicates via CAML FSObjType=1
- MakeKey composite key logic for grouping duplicates by name+size+dates+counts
affects: [03-05, 03-07, 03-08]
# Tech tracking
tech-stack:
added: []
patterns:
- "KeywordQuery + SearchExecutor pattern: executor.ExecuteQuery(kq) registers query, then ExecuteQueryRetryHelper.ExecuteQueryRetryAsync executes it"
- "StringCollection.Add loop: SelectProperties is StringCollection, not List<string> — must add properties one-by-one"
- "StartRow pagination: += BatchSize per iteration, hard stop at MaxStartRow (50,000)"
- "goto done pattern for early exit from nested pagination loop when MaxResults reached"
key-files:
created:
- SharepointToolbox/Services/SearchService.cs
- SharepointToolbox/Services/DuplicatesService.cs
modified: []
key-decisions:
- "SearchService uses SelectProperties.Add per-item loop — StringCollection has no AddRange(string[]) overload in this SDK version"
- "DuplicatesService.MakeKey internal static method matches inline test helper in DuplicatesServiceTests exactly — deliberate design to ensure test parity"
- "DuplicatesService file mode re-implements pagination inline (not delegating to SearchService) — avoids coupling between services with different result models"
patterns-established:
- "KQL SelectProperties: Add each property in a foreach loop, never AddRange with array"
- "Search pagination: do/while with startRow <= MaxStartRow guard, break on empty table"
- "Folder CAML: FSObjType=1 (not FileSystemObjectType) — wrong name returns zero results"
requirements-completed: [SRCH-01, SRCH-02, DUPL-01, DUPL-02]
# Metrics
duration: 2min
completed: 2026-04-02
---
# Phase 03 Plan 04: SearchService and DuplicatesService Summary
**KQL file search with 500-row StartRow pagination (50k cap) and composite-key duplicate detection for files (Search API) and folders (CAML FSObjType=1)**
## Performance
- **Duration:** 2 min
- **Started:** 2026-04-02T14:09:25Z
- **Completed:** 2026-04-02T14:12:09Z
- **Tasks:** 2
- **Files modified:** 2 created
## Accomplishments
- SearchService implements full KQL builder (extension, date range, creator, editor, library filters) with paginated retrieval up to 50,000 items
- DuplicatesService supports both file mode (Search API) and folder mode (CAML FSObjType=1) with client-side composite key grouping
- MakeKey logic matches the inline test scaffold from Plan 03-01 DuplicatesServiceTests — 5 pure-logic tests pass
## Task Commits
Each task was committed atomically:
1. **Task 1: Implement SearchService** - `9e3d501` (feat)
2. **Task 2: Implement DuplicatesService** - `df5f79d` (feat)
## Files Created/Modified
- `SharepointToolbox/Services/SearchService.cs` - KQL search with pagination, vti_history filter, regex client-side filter, KQL length validation
- `SharepointToolbox/Services/DuplicatesService.cs` - File/folder duplicate detection, MakeKey composite grouping, CAML folder enumeration
## Decisions Made
- `SelectProperties` is a `StringCollection``AddRange(string[])` does not compile. Fixed inline per-item `foreach` add loop (Rule 1 auto-fix applied during Task 1 first build).
- DuplicatesService re-implements file pagination inline rather than delegating to SearchService because result types differ (`DuplicateItem` vs `SearchResult`) and the two services have different lifecycles.
- `MakeKey` is `internal static` to match the test project's inline copy — enables verifying parity without a live CSOM context.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] StringCollection.AddRange(string[]) does not exist**
- **Found during:** Task 1 (SearchService build)
- **Issue:** `kq.SelectProperties.AddRange(new[] { ... })``SelectProperties` is `StringCollection` which has no `AddRange` taking `string[]`; extension method overload requires `List<string>` receiver
- **Fix:** Replaced with `foreach` loop calling `kq.SelectProperties.Add(prop)` for each property name
- **Files modified:** `SharepointToolbox/Services/SearchService.cs`, `SharepointToolbox/Services/DuplicatesService.cs`
- **Verification:** `dotnet build` 0 errors after fix; same fix proactively applied in DuplicatesService before its first build
- **Committed in:** `9e3d501` (Task 1 commit)
---
**Total deviations:** 1 auto-fixed (Rule 1 - bug)
**Impact on plan:** Minor API surface mismatch in the plan's code listing; fix is purely syntactic, no behavioral difference.
## Issues Encountered
- `dotnet test ... -x` flag not recognized by the `dotnet test` CLI on this machine (MSBuild switch error). Removed the flag; tests ran correctly without it.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- SearchService and DuplicatesService are complete and compile cleanly
- Wave 2 is now ready for 03-05 (Search/Duplicate exports) and 03-06 (Localization) to proceed in parallel with 03-03 (Storage exports)
- 5 MakeKey tests pass; CSOM integration tests will remain skipped until a live tenant is available
---
*Phase: 03-storage*
*Completed: 2026-04-02*
## Self-Check: PASSED
- SharepointToolbox/Services/SearchService.cs: FOUND
- SharepointToolbox/Services/DuplicatesService.cs: FOUND
- .planning/phases/03-storage/03-04-SUMMARY.md: FOUND
- Commit 9e3d501 (SearchService): FOUND
- Commit df5f79d (DuplicatesService): FOUND

View File

@@ -0,0 +1,459 @@
---
phase: 03
plan: 05
title: Search and Duplicate Export Services — CSV, Sortable HTML, and Grouped HTML
status: pending
wave: 3
depends_on:
- 03-04
files_modified:
- SharepointToolbox/Services/Export/SearchCsvExportService.cs
- SharepointToolbox/Services/Export/SearchHtmlExportService.cs
- SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs
autonomous: true
requirements:
- SRCH-03
- SRCH-04
- DUPL-03
must_haves:
truths:
- "SearchCsvExportService.BuildCsv produces a UTF-8 BOM CSV with header: File Name, Extension, Path, Created, Created By, Modified, Modified By, Size (bytes)"
- "SearchHtmlExportService.BuildHtml produces a self-contained HTML with sortable columns (click-to-sort JS) and a filter/search input"
- "DuplicatesHtmlExportService.BuildHtml produces a self-contained HTML with one card per group, showing item paths, and an ok/diff badge indicating group size"
- "SearchExportServiceTests: all 6 tests pass"
- "DuplicatesHtmlExportServiceTests: all 3 tests pass"
artifacts:
- path: "SharepointToolbox/Services/Export/SearchCsvExportService.cs"
provides: "CSV exporter for SearchResult list (SRCH-03)"
exports: ["SearchCsvExportService"]
- path: "SharepointToolbox/Services/Export/SearchHtmlExportService.cs"
provides: "Sortable/filterable HTML exporter for SearchResult list (SRCH-04)"
exports: ["SearchHtmlExportService"]
- path: "SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs"
provides: "Grouped HTML exporter for DuplicateGroup list (DUPL-03)"
exports: ["DuplicatesHtmlExportService"]
key_links:
- from: "SearchHtmlExportService.cs"
to: "sortTable JS"
via: "inline script"
pattern: "sort"
- from: "DuplicatesHtmlExportService.cs"
to: "group card HTML"
via: "per-DuplicateGroup rendering"
pattern: "group"
---
# Plan 03-05: Search and Duplicate Export Services — CSV, Sortable HTML, and Grouped HTML
## Goal
Replace the three stub export implementations created in Plan 03-01 with real ones. `SearchCsvExportService` produces a UTF-8 BOM CSV. `SearchHtmlExportService` ports the PS `Export-SearchToHTML` pattern (PS lines 2112-2233) with sortable columns and a live filter input. `DuplicatesHtmlExportService` ports the PS `Export-DuplicatesToHTML` pattern (PS lines 2235-2406) with grouped cards and ok/diff badges.
## Context
Test files `SearchExportServiceTests.cs` and `DuplicatesHtmlExportServiceTests.cs` already exist from Plan 03-01 and currently fail because stubs return `string.Empty`. This plan makes them pass.
All HTML exports are self-contained (no external CDN or CSS links) using the same `Segoe UI` font stack and `#0078d4` color palette established in Phase 2.
## Tasks
### Task 1: Implement SearchCsvExportService and SearchHtmlExportService
**Files:**
- `SharepointToolbox/Services/Export/SearchCsvExportService.cs`
- `SharepointToolbox/Services/Export/SearchHtmlExportService.cs`
**Action:** Modify (replace stubs with full implementation)
**Why:** SRCH-03 (CSV export) and SRCH-04 (sortable/filterable HTML export).
```csharp
// SharepointToolbox/Services/Export/SearchCsvExportService.cs
using SharepointToolbox.Core.Models;
using System.Text;
namespace SharepointToolbox.Services.Export;
/// <summary>
/// Exports SearchResult list to a UTF-8 BOM CSV file.
/// Header matches the column order in SearchHtmlExportService for consistency.
/// </summary>
public class SearchCsvExportService
{
public string BuildCsv(IReadOnlyList<SearchResult> results)
{
var sb = new StringBuilder();
// Header
sb.AppendLine("File Name,Extension,Path,Created,Created By,Modified,Modified By,Size (bytes)");
foreach (var r in results)
{
sb.AppendLine(string.Join(",",
Csv(IfEmpty(System.IO.Path.GetFileName(r.Path), r.Title)),
Csv(r.FileExtension),
Csv(r.Path),
r.Created.HasValue ? Csv(r.Created.Value.ToString("yyyy-MM-dd")) : string.Empty,
Csv(r.Author),
r.LastModified.HasValue ? Csv(r.LastModified.Value.ToString("yyyy-MM-dd")) : string.Empty,
Csv(r.ModifiedBy),
r.SizeBytes.ToString()));
}
return sb.ToString();
}
public async Task WriteAsync(IReadOnlyList<SearchResult> results, string filePath, CancellationToken ct)
{
var csv = BuildCsv(results);
await File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
}
private static string Csv(string value)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return $"\"{value.Replace("\"", "\"\"")}\"";
return value;
}
private static string IfEmpty(string? value, string fallback = "")
=> string.IsNullOrEmpty(value) ? fallback : value!;
}
```
```csharp
// SharepointToolbox/Services/Export/SearchHtmlExportService.cs
using SharepointToolbox.Core.Models;
using System.Text;
namespace SharepointToolbox.Services.Export;
/// <summary>
/// Exports SearchResult list to a self-contained sortable/filterable HTML report.
/// Port of PS Export-SearchToHTML (PS lines 2112-2233).
/// Columns are sortable by clicking the header. A filter input narrows rows by text match.
/// </summary>
public class SearchHtmlExportService
{
public string BuildHtml(IReadOnlyList<SearchResult> results)
{
var sb = new StringBuilder();
sb.AppendLine("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SharePoint File Search Results</title>
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
h1 { color: #0078d4; }
.toolbar { margin-bottom: 10px; display: flex; gap: 12px; align-items: center; }
.toolbar label { font-weight: 600; }
#filterInput { padding: 6px 10px; border: 1px solid #ccc; border-radius: 4px; width: 280px; font-size: 13px; }
#resultCount { font-size: 12px; color: #666; }
table { border-collapse: collapse; width: 100%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.15); }
th { background: #0078d4; color: #fff; padding: 8px 12px; text-align: left; cursor: pointer;
font-weight: 600; user-select: none; white-space: nowrap; }
th:hover { background: #106ebe; }
th.sorted-asc::after { content: ' '; font-size: 10px; }
th.sorted-desc::after { content: ' '; font-size: 10px; }
td { padding: 6px 12px; border-bottom: 1px solid #e0e0e0; word-break: break-all; }
tr:hover td { background: #f0f7ff; }
tr.hidden { display: none; }
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.generated { font-size: 11px; color: #888; margin-top: 12px; }
</style>
</head>
<body>
<h1>File Search Results</h1>
<div class="toolbar">
<label for="filterInput">Filter:</label>
<input id="filterInput" type="text" placeholder="Filter rows…" oninput="filterTable()" />
<span id="resultCount"></span>
</div>
""");
sb.AppendLine("""
<table id="resultsTable">
<thead>
<tr>
<th onclick="sortTable(0)">File Name</th>
<th onclick="sortTable(1)">Extension</th>
<th onclick="sortTable(2)">Path</th>
<th onclick="sortTable(3)">Created</th>
<th onclick="sortTable(4)">Created By</th>
<th onclick="sortTable(5)">Modified</th>
<th onclick="sortTable(6)">Modified By</th>
<th class="num" onclick="sortTable(7)">Size</th>
</tr>
</thead>
<tbody>
""");
foreach (var r in results)
{
string fileName = System.IO.Path.GetFileName(r.Path);
if (string.IsNullOrEmpty(fileName)) fileName = r.Title;
sb.AppendLine($"""
<tr>
<td>{H(fileName)}</td>
<td>{H(r.FileExtension)}</td>
<td><a href="{H(r.Path)}" target="_blank">{H(r.Path)}</a></td>
<td>{(r.Created.HasValue ? r.Created.Value.ToString("yyyy-MM-dd") : string.Empty)}</td>
<td>{H(r.Author)}</td>
<td>{(r.LastModified.HasValue ? r.LastModified.Value.ToString("yyyy-MM-dd") : string.Empty)}</td>
<td>{H(r.ModifiedBy)}</td>
<td class="num" data-sort="{r.SizeBytes}">{FormatSize(r.SizeBytes)}</td>
</tr>
""");
}
sb.AppendLine(" </tbody>\n</table>");
// Inline sort + filter JS
sb.AppendLine($$"""
<p class="generated">Generated: {{DateTime.Now:yyyy-MM-dd HH:mm}} — {{results.Count:N0}} result(s)</p>
<script>
var sortDir = {};
function sortTable(col) {
var tbl = document.getElementById('resultsTable');
var tbody = tbl.tBodies[0];
var rows = Array.from(tbody.rows);
var asc = sortDir[col] !== 'asc';
sortDir[col] = asc ? 'asc' : 'desc';
rows.sort(function(a, b) {
var av = a.cells[col].dataset.sort || a.cells[col].innerText;
var bv = b.cells[col].dataset.sort || b.cells[col].innerText;
var an = parseFloat(av), bn = parseFloat(bv);
if (!isNaN(an) && !isNaN(bn)) return asc ? an - bn : bn - an;
return asc ? av.localeCompare(bv) : bv.localeCompare(av);
});
rows.forEach(function(r) { tbody.appendChild(r); });
var ths = tbl.tHead.rows[0].cells;
for (var i = 0; i < ths.length; i++) {
ths[i].className = (i === col) ? (asc ? 'sorted-asc' : 'sorted-desc') : '';
}
}
function filterTable() {
var q = document.getElementById('filterInput').value.toLowerCase();
var rows = document.getElementById('resultsTable').tBodies[0].rows;
var visible = 0;
for (var i = 0; i < rows.length; i++) {
var match = rows[i].innerText.toLowerCase().indexOf(q) >= 0;
rows[i].className = match ? '' : 'hidden';
if (match) visible++;
}
document.getElementById('resultCount').innerText = q ? (visible + ' of {{results.Count:N0}} shown') : '';
}
window.onload = function() {
document.getElementById('resultCount').innerText = '{{results.Count:N0}} result(s)';
};
</script>
</body></html>
""");
return sb.ToString();
}
public async Task WriteAsync(IReadOnlyList<SearchResult> results, string filePath, CancellationToken ct)
{
var html = BuildHtml(results);
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
}
private static string H(string value) =>
System.Net.WebUtility.HtmlEncode(value ?? string.Empty);
private static string FormatSize(long bytes)
{
if (bytes >= 1_073_741_824L) return $"{bytes / 1_073_741_824.0:F2} GB";
if (bytes >= 1_048_576L) return $"{bytes / 1_048_576.0:F2} MB";
if (bytes >= 1024L) return $"{bytes / 1024.0:F2} KB";
return $"{bytes} B";
}
}
```
**Verification:**
```bash
dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~SearchExportServiceTests" -x
```
Expected: 6 tests pass
### Task 2: Implement DuplicatesHtmlExportService
**File:** `SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs`
**Action:** Modify (replace stub with full implementation)
**Why:** DUPL-03 — user can export duplicate report to HTML with grouped display and visual indicators.
```csharp
// SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs
using SharepointToolbox.Core.Models;
using System.Text;
namespace SharepointToolbox.Services.Export;
/// <summary>
/// Exports DuplicateGroup list to a self-contained HTML with collapsible group cards.
/// Port of PS Export-DuplicatesToHTML (PS lines 2235-2406).
/// Each group gets a card showing item count badge and a table of paths.
/// </summary>
public class DuplicatesHtmlExportService
{
public string BuildHtml(IReadOnlyList<DuplicateGroup> groups)
{
var sb = new StringBuilder();
sb.AppendLine("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SharePoint Duplicate Detection Report</title>
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
h1 { color: #0078d4; }
.summary { margin-bottom: 16px; font-size: 12px; color: #444; }
.group-card { background: #fff; border: 1px solid #ddd; border-radius: 6px;
margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,.1); overflow: hidden; }
.group-header { background: #0078d4; color: #fff; padding: 8px 14px;
display: flex; align-items: center; justify-content: space-between;
cursor: pointer; user-select: none; }
.group-header:hover { background: #106ebe; }
.group-name { font-weight: 600; font-size: 14px; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px;
font-size: 11px; font-weight: 700; }
.badge-dup { background: #e53935; color: #fff; }
.group-body { padding: 0; }
table { width: 100%; border-collapse: collapse; }
th { background: #f0f7ff; color: #333; padding: 6px 12px; text-align: left;
font-weight: 600; border-bottom: 1px solid #ddd; font-size: 12px; }
td { padding: 5px 12px; border-bottom: 1px solid #eee; font-size: 12px; word-break: break-all; }
tr:last-child td { border-bottom: none; }
.collapsed { display: none; }
.generated { font-size: 11px; color: #888; margin-top: 16px; }
</style>
<script>
function toggleGroup(id) {
var body = document.getElementById('gb-' + id);
if (body) body.classList.toggle('collapsed');
}
</script>
</head>
<body>
<h1>Duplicate Detection Report</h1>
""");
sb.AppendLine($"<p class=\"summary\">{groups.Count:N0} duplicate group(s) found.</p>");
for (int i = 0; i < groups.Count; i++)
{
var g = groups[i];
int count = g.Items.Count;
string badgeClass = "badge-dup";
sb.AppendLine($"""
<div class="group-card">
<div class="group-header" onclick="toggleGroup({i})">
<span class="group-name">{H(g.Name)}</span>
<span class="badge {badgeClass}">{count} copies</span>
</div>
<div class="group-body" id="gb-{i}">
<table>
<thead>
<tr>
<th>#</th>
<th>Library</th>
<th>Path</th>
<th>Size</th>
<th>Created</th>
<th>Modified</th>
</tr>
</thead>
<tbody>
""");
for (int j = 0; j < g.Items.Count; j++)
{
var item = g.Items[j];
string size = item.SizeBytes.HasValue ? FormatSize(item.SizeBytes.Value) : string.Empty;
string created = item.Created.HasValue ? item.Created.Value.ToString("yyyy-MM-dd") : string.Empty;
string modified = item.Modified.HasValue ? item.Modified.Value.ToString("yyyy-MM-dd") : string.Empty;
sb.AppendLine($"""
<tr>
<td>{j + 1}</td>
<td>{H(item.Library)}</td>
<td><a href="{H(item.Path)}" target="_blank">{H(item.Path)}</a></td>
<td>{size}</td>
<td>{created}</td>
<td>{modified}</td>
</tr>
""");
}
sb.AppendLine("""
</tbody>
</table>
</div>
</div>
""");
}
sb.AppendLine($"<p class=\"generated\">Generated: {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
sb.AppendLine("</body></html>");
return sb.ToString();
}
public async Task WriteAsync(IReadOnlyList<DuplicateGroup> groups, string filePath, CancellationToken ct)
{
var html = BuildHtml(groups);
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
}
private static string H(string value) =>
System.Net.WebUtility.HtmlEncode(value ?? string.Empty);
private static string FormatSize(long bytes)
{
if (bytes >= 1_073_741_824L) return $"{bytes / 1_073_741_824.0:F2} GB";
if (bytes >= 1_048_576L) return $"{bytes / 1_048_576.0:F2} MB";
if (bytes >= 1024L) return $"{bytes / 1024.0:F2} KB";
return $"{bytes} B";
}
}
```
**Verification:**
```bash
dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~DuplicatesHtmlExportServiceTests" -x
```
Expected: 3 tests pass
## Verification
```bash
dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~SearchExportServiceTests|FullyQualifiedName~DuplicatesHtmlExportServiceTests" -x
```
Expected: 9 tests pass, 0 fail
## Commit Message
feat(03-05): implement SearchCsvExportService, SearchHtmlExportService, DuplicatesHtmlExportService
## Output
After completion, create `.planning/phases/03-storage/03-05-SUMMARY.md`

View File

@@ -0,0 +1,124 @@
---
phase: 03-storage
plan: 05
subsystem: export
tags: [csharp, csv, html, search, duplicates, export]
# Dependency graph
requires:
- phase: 03-01
provides: export stubs and test scaffolds for SearchCsvExportService, SearchHtmlExportService, DuplicatesHtmlExportService
- phase: 03-04
provides: SearchResult and DuplicateGroup models consumed by exporters
provides:
- SearchCsvExportService: UTF-8 BOM CSV with 8-column header for SearchResult list
- SearchHtmlExportService: self-contained sortable/filterable HTML report for SearchResult list
- DuplicatesHtmlExportService: grouped card HTML report for DuplicateGroup list
affects: [03-08, SearchViewModel, DuplicatesViewModel]
# Tech tracking
tech-stack:
added: []
patterns:
- "System.IO.File used explicitly in WPF project (no implicit using for System.IO)"
- "Self-contained HTML exports with inline CSS + JS (no external CDN dependencies)"
- "Segoe UI font stack and #0078d4 color palette consistent across all Phase 2/3 HTML exports"
key-files:
created: []
modified:
- SharepointToolbox/Services/Export/SearchCsvExportService.cs
- SharepointToolbox/Services/Export/SearchHtmlExportService.cs
- SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs
key-decisions:
- "SearchCsvExportService uses UTF-8 BOM (encoderShouldEmitUTF8Identifier: true) for Excel compatibility"
- "SearchHtmlExportService result count rendered at generation time (not via JS variable) to avoid C# interpolation conflicts with JS template strings"
- "DuplicatesHtmlExportService always uses badge-dup class (red) — no ok/diff distinction needed per DUPL-03"
patterns-established:
- "sortTable(col) JS function: uses data-sort attribute for numeric columns (Size), falls back to innerText"
- "filterTable() JS function: hides rows by adding 'hidden' class, updates result count display"
- "Group cards use toggleGroup(id) with collapsed CSS class for collapsible behavior"
requirements-completed: [SRCH-03, SRCH-04, DUPL-03]
# Metrics
duration: 4min
completed: 2026-04-02
---
# Phase 03 Plan 05: Search and Duplicate Export Services Summary
**SearchCsvExportService (UTF-8 BOM CSV), SearchHtmlExportService (sortable/filterable HTML), and DuplicatesHtmlExportService (grouped card HTML) — all 9 export tests pass**
## Performance
- **Duration:** 4 min
- **Started:** 2026-04-02T13:34:47Z
- **Completed:** 2026-04-02T13:38:47Z
- **Tasks:** 2
- **Files modified:** 3
## Accomplishments
- SearchCsvExportService: UTF-8 BOM CSV with proper 8-column header and RFC 4180 CSV escaping
- SearchHtmlExportService: self-contained HTML with click-to-sort columns and live filter input, ported from PS Export-SearchToHTML
- DuplicatesHtmlExportService: collapsible group cards with item count badges and path tables, ported from PS Export-DuplicatesToHTML
## Task Commits
Each task was committed atomically:
1. **Task 1: SearchCsvExportService + SearchHtmlExportService** - `e174a18` (feat, part of 03-07 session)
2. **Task 2: DuplicatesHtmlExportService** - `fc1ba00` (feat)
**Plan metadata:** (see final docs commit)
## Files Created/Modified
- `SharepointToolbox/Services/Export/SearchCsvExportService.cs` - UTF-8 BOM CSV exporter for SearchResult list (SRCH-03)
- `SharepointToolbox/Services/Export/SearchHtmlExportService.cs` - Sortable/filterable HTML exporter for SearchResult list (SRCH-04)
- `SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs` - Grouped card HTML exporter for DuplicateGroup list (DUPL-03)
## Decisions Made
- `SearchCsvExportService` uses `UTF8Encoding(encoderShouldEmitUTF8Identifier: true)` for Excel compatibility — consistent with Phase 2 CsvExportService pattern
- Result count in `SearchHtmlExportService` is rendered as a C# interpolated string at generation time rather than a JS variable — avoids conflict between C# `$$"""` interpolation and JS template literal syntax
- `DuplicatesHtmlExportService` uses `badge-dup` (red) for all groups — DUPL-03 requires showing copies count; ok/diff distinction was removed from final spec
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Fixed implicit `File` class resolution in WPF project**
- **Found during:** Task 1 (SearchCsvExportService and SearchHtmlExportService)
- **Issue:** `File.WriteAllTextAsync` fails to compile — WPF project does not include `System.IO` in implicit usings (established project pattern documented in STATE.md decisions)
- **Fix:** Changed `File.WriteAllTextAsync` to `System.IO.File.WriteAllTextAsync` in both services
- **Files modified:** SearchCsvExportService.cs, SearchHtmlExportService.cs
- **Verification:** Test project builds successfully; 6/6 SearchExportServiceTests pass
- **Committed in:** e174a18 (Task 1 commit, part of 03-07 session)
---
**Total deviations:** 1 auto-fixed (Rule 1 — known WPF project pattern)
**Impact on plan:** Necessary correctness fix. No scope creep.
## Issues Encountered
- Task 1 (SearchCsvExportService + SearchHtmlExportService) was already committed in the prior `feat(03-07)` session — the plan was executed out of order. Task 2 (DuplicatesHtmlExportService) was the only remaining work in this session.
- WPF temp project (`_wpftmp.csproj`) showed pre-existing errors for `StorageView` and `ClientRuntimeContext.Url` during build attempts — these are pre-existing blockers from plan 03-07 state (StorageView untracked, not in scope for this plan). Used `dotnet build SharepointToolbox.Tests/` directly to avoid them.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- All 3 export services are fully implemented and tested (9/9 tests pass)
- SearchViewModel and DuplicatesViewModel (plan 03-08) can now wire export commands to these services
- StorageView.xaml is untracked (created in 03-07 session) — needs to be committed before plan 03-08 runs
---
*Phase: 03-storage*
*Completed: 2026-04-02*

View File

@@ -0,0 +1,301 @@
---
phase: 03
plan: 06
title: Localization — Phase 3 EN and FR Keys
status: pending
wave: 2
depends_on:
- 03-01
files_modified:
- SharepointToolbox/Localization/Strings.resx
- SharepointToolbox/Localization/Strings.fr.resx
- SharepointToolbox/Localization/Strings.Designer.cs
autonomous: true
requirements:
- STOR-01
- STOR-02
- STOR-04
- STOR-05
- SRCH-01
- SRCH-02
- SRCH-03
- SRCH-04
- DUPL-01
- DUPL-02
- DUPL-03
must_haves:
truths:
- "All Phase 3 EN keys exist in Strings.resx"
- "All Phase 3 FR keys exist in Strings.fr.resx with non-empty French values"
- "Strings.Designer.cs has one static property per new key (dot-to-underscore naming: chk.per.lib -> chk_per_lib)"
- "dotnet build produces 0 errors after localization changes"
- "No existing Phase 2 or Phase 1 keys are modified or removed"
artifacts:
- path: "SharepointToolbox/Localization/Strings.resx"
provides: "English localization for Phase 3 tabs"
- path: "SharepointToolbox/Localization/Strings.fr.resx"
provides: "French localization for Phase 3 tabs"
- path: "SharepointToolbox/Localization/Strings.Designer.cs"
provides: "Strongly-typed accessors for new keys"
key_links:
- from: "Strings.Designer.cs"
to: "Strings.resx"
via: "ResourceManager.GetString"
pattern: "ResourceManager\\.GetString"
---
# Plan 03-06: Localization — Phase 3 EN and FR Keys
## Goal
Add all EN and FR localization keys needed by the Storage, File Search, and Duplicates tabs. Views in plans 03-07 and 03-08 reference these keys via `TranslationSource.Instance["key"]` XAML bindings. Keys must exist before the Views compile.
## Context
Strings.resx uses a manually maintained `Strings.Designer.cs` (no ResXFileCodeGenerator — confirmed in Phase 1 decisions). The naming convention converts dots to underscores: key `chk.per.lib` becomes accessor `Strings.chk_per_lib`. Both `.resx` files use `xml:space="preserve"` on each `<data>` element. The following keys already exist and must NOT be duplicated: `tab.storage`, `tab.search`, `tab.duplicates`, `lbl.folder.depth`, `chk.max.depth`.
> **Pre-existing keys — do not add:** The following keys are confirmed present in `Strings.resx` from Phase 2 and must be skipped when editing both `.resx` files and `Strings.Designer.cs`:
> - `grp.scan.opts` (value: "Scan Options") — already exists
> - `grp.export.fmt` (value: "Export Format") — already exists
> - `btn.cancel` (value: "Cancel") — already exists
>
> Before appending, verify with: `grep -n "grp.scan.opts\|grp.export.fmt\|btn.cancel" SharepointToolbox/Localization/Strings.resx`
> Do not add designer properties for these keys if they already exist in `Strings.Designer.cs`.
## Tasks
### Task 1: Add Phase 3 keys to Strings.resx, Strings.fr.resx, and Strings.Designer.cs
**Files:**
- `SharepointToolbox/Localization/Strings.resx`
- `SharepointToolbox/Localization/Strings.fr.resx`
- `SharepointToolbox/Localization/Strings.Designer.cs`
**Action:** Modify — append new `<data>` elements before `</root>` in both .resx files; append new properties before the closing `}` in Strings.Designer.cs
**Why:** Views in plans 03-07 and 03-08 bind to these keys. Missing keys produce empty strings at runtime.
Add these entries immediately before the closing `</root>` tag in `Strings.resx`:
```xml
<!-- Phase 3: Storage Tab -->
<data name="chk.per.lib" xml:space="preserve"><value>Per-Library Breakdown</value></data>
<data name="chk.subsites" xml:space="preserve"><value>Include Subsites</value></data>
<data name="stor.note" xml:space="preserve"><value>Note: deeper folder scans on large sites may take several minutes.</value></data>
<data name="btn.gen.storage" xml:space="preserve"><value>Generate Metrics</value></data>
<data name="btn.open.storage" xml:space="preserve"><value>Open Report</value></data>
<data name="stor.col.library" xml:space="preserve"><value>Library</value></data>
<data name="stor.col.site" xml:space="preserve"><value>Site</value></data>
<data name="stor.col.files" xml:space="preserve"><value>Files</value></data>
<data name="stor.col.size" xml:space="preserve"><value>Total Size</value></data>
<data name="stor.col.versions" xml:space="preserve"><value>Version Size</value></data>
<data name="stor.col.lastmod" xml:space="preserve"><value>Last Modified</value></data>
<data name="stor.col.share" xml:space="preserve"><value>Share of Total</value></data>
<data name="stor.rad.csv" xml:space="preserve"><value>CSV</value></data>
<data name="stor.rad.html" xml:space="preserve"><value>HTML</value></data>
<!-- Phase 3: File Search Tab -->
<data name="grp.search.filters" xml:space="preserve"><value>Search Filters</value></data>
<data name="lbl.extensions" xml:space="preserve"><value>Extension(s):</value></data>
<data name="ph.extensions" xml:space="preserve"><value>docx pdf xlsx</value></data>
<data name="lbl.regex" xml:space="preserve"><value>Name / Regex:</value></data>
<data name="ph.regex" xml:space="preserve"><value>Ex: report.* or \.bak$</value></data>
<data name="chk.created.after" xml:space="preserve"><value>Created after:</value></data>
<data name="chk.created.before" xml:space="preserve"><value>Created before:</value></data>
<data name="chk.modified.after" xml:space="preserve"><value>Modified after:</value></data>
<data name="chk.modified.before" xml:space="preserve"><value>Modified before:</value></data>
<data name="lbl.created.by" xml:space="preserve"><value>Created by:</value></data>
<data name="ph.created.by" xml:space="preserve"><value>First Last or email</value></data>
<data name="lbl.modified.by" xml:space="preserve"><value>Modified by:</value></data>
<data name="ph.modified.by" xml:space="preserve"><value>First Last or email</value></data>
<data name="lbl.library" xml:space="preserve"><value>Library:</value></data>
<data name="ph.library" xml:space="preserve"><value>Optional relative path e.g. Shared Documents</value></data>
<data name="lbl.max.results" xml:space="preserve"><value>Max results:</value></data>
<data name="lbl.site.url" xml:space="preserve"><value>Site URL:</value></data>
<data name="ph.site.url" xml:space="preserve"><value>https://tenant.sharepoint.com/sites/MySite</value></data>
<data name="btn.run.search" xml:space="preserve"><value>Run Search</value></data>
<data name="btn.open.search" xml:space="preserve"><value>Open Results</value></data>
<data name="srch.col.name" xml:space="preserve"><value>File Name</value></data>
<data name="srch.col.ext" xml:space="preserve"><value>Extension</value></data>
<data name="srch.col.created" xml:space="preserve"><value>Created</value></data>
<data name="srch.col.modified" xml:space="preserve"><value>Modified</value></data>
<data name="srch.col.author" xml:space="preserve"><value>Created By</value></data>
<data name="srch.col.modby" xml:space="preserve"><value>Modified By</value></data>
<data name="srch.col.size" xml:space="preserve"><value>Size</value></data>
<data name="srch.col.path" xml:space="preserve"><value>Path</value></data>
<data name="srch.rad.csv" xml:space="preserve"><value>CSV</value></data>
<data name="srch.rad.html" xml:space="preserve"><value>HTML</value></data>
<!-- Phase 3: Duplicates Tab -->
<data name="grp.dup.type" xml:space="preserve"><value>Duplicate Type</value></data>
<data name="rad.dup.files" xml:space="preserve"><value>Duplicate files</value></data>
<data name="rad.dup.folders" xml:space="preserve"><value>Duplicate folders</value></data>
<data name="grp.dup.criteria" xml:space="preserve"><value>Comparison Criteria</value></data>
<data name="lbl.dup.note" xml:space="preserve"><value>Name is always the primary criterion. Check additional criteria:</value></data>
<data name="chk.dup.size" xml:space="preserve"><value>Same size</value></data>
<data name="chk.dup.created" xml:space="preserve"><value>Same creation date</value></data>
<data name="chk.dup.modified" xml:space="preserve"><value>Same modification date</value></data>
<data name="chk.dup.subfolders" xml:space="preserve"><value>Same subfolder count</value></data>
<data name="chk.dup.filecount" xml:space="preserve"><value>Same file count</value></data>
<data name="chk.include.subsites" xml:space="preserve"><value>Include subsites</value></data>
<data name="ph.dup.lib" xml:space="preserve"><value>All (leave empty)</value></data>
<data name="btn.run.scan" xml:space="preserve"><value>Run Scan</value></data>
<data name="btn.open.results" xml:space="preserve"><value>Open Results</value></data>
```
Add these entries immediately before the closing `</root>` tag in `Strings.fr.resx`:
```xml
<!-- Phase 3: Storage Tab -->
<data name="chk.per.lib" xml:space="preserve"><value>Détail par bibliothèque</value></data>
<data name="chk.subsites" xml:space="preserve"><value>Inclure les sous-sites</value></data>
<data name="stor.note" xml:space="preserve"><value>Remarque : les analyses de dossiers profondes sur les grands sites peuvent prendre plusieurs minutes.</value></data>
<data name="btn.gen.storage" xml:space="preserve"><value>Générer les métriques</value></data>
<data name="btn.open.storage" xml:space="preserve"><value>Ouvrir le rapport</value></data>
<data name="stor.col.library" xml:space="preserve"><value>Bibliothèque</value></data>
<data name="stor.col.site" xml:space="preserve"><value>Site</value></data>
<data name="stor.col.files" xml:space="preserve"><value>Fichiers</value></data>
<data name="stor.col.size" xml:space="preserve"><value>Taille totale</value></data>
<data name="stor.col.versions" xml:space="preserve"><value>Taille des versions</value></data>
<data name="stor.col.lastmod" xml:space="preserve"><value>Dernière modification</value></data>
<data name="stor.col.share" xml:space="preserve"><value>Part du total</value></data>
<data name="stor.rad.csv" xml:space="preserve"><value>CSV</value></data>
<data name="stor.rad.html" xml:space="preserve"><value>HTML</value></data>
<!-- Phase 3: File Search Tab -->
<data name="grp.search.filters" xml:space="preserve"><value>Filtres de recherche</value></data>
<data name="lbl.extensions" xml:space="preserve"><value>Extension(s) :</value></data>
<data name="ph.extensions" xml:space="preserve"><value>docx pdf xlsx</value></data>
<data name="lbl.regex" xml:space="preserve"><value>Nom / Regex :</value></data>
<data name="ph.regex" xml:space="preserve"><value>Ex : rapport.* ou \.bak$</value></data>
<data name="chk.created.after" xml:space="preserve"><value>Créé après :</value></data>
<data name="chk.created.before" xml:space="preserve"><value>Créé avant :</value></data>
<data name="chk.modified.after" xml:space="preserve"><value>Modifié après :</value></data>
<data name="chk.modified.before" xml:space="preserve"><value>Modifié avant :</value></data>
<data name="lbl.created.by" xml:space="preserve"><value>Créé par :</value></data>
<data name="ph.created.by" xml:space="preserve"><value>Prénom Nom ou courriel</value></data>
<data name="lbl.modified.by" xml:space="preserve"><value>Modifié par :</value></data>
<data name="ph.modified.by" xml:space="preserve"><value>Prénom Nom ou courriel</value></data>
<data name="lbl.library" xml:space="preserve"><value>Bibliothèque :</value></data>
<data name="ph.library" xml:space="preserve"><value>Chemin relatif optionnel, ex. Documents partagés</value></data>
<data name="lbl.max.results" xml:space="preserve"><value>Max résultats :</value></data>
<data name="lbl.site.url" xml:space="preserve"><value>URL du site :</value></data>
<data name="ph.site.url" xml:space="preserve"><value>https://tenant.sharepoint.com/sites/MonSite</value></data>
<data name="btn.run.search" xml:space="preserve"><value>Lancer la recherche</value></data>
<data name="btn.open.search" xml:space="preserve"><value>Ouvrir les résultats</value></data>
<data name="srch.col.name" xml:space="preserve"><value>Nom du fichier</value></data>
<data name="srch.col.ext" xml:space="preserve"><value>Extension</value></data>
<data name="srch.col.created" xml:space="preserve"><value>Créé</value></data>
<data name="srch.col.modified" xml:space="preserve"><value>Modifié</value></data>
<data name="srch.col.author" xml:space="preserve"><value>Créé par</value></data>
<data name="srch.col.modby" xml:space="preserve"><value>Modifié par</value></data>
<data name="srch.col.size" xml:space="preserve"><value>Taille</value></data>
<data name="srch.col.path" xml:space="preserve"><value>Chemin</value></data>
<data name="srch.rad.csv" xml:space="preserve"><value>CSV</value></data>
<data name="srch.rad.html" xml:space="preserve"><value>HTML</value></data>
<!-- Phase 3: Duplicates Tab -->
<data name="grp.dup.type" xml:space="preserve"><value>Type de doublon</value></data>
<data name="rad.dup.files" xml:space="preserve"><value>Fichiers en doublon</value></data>
<data name="rad.dup.folders" xml:space="preserve"><value>Dossiers en doublon</value></data>
<data name="grp.dup.criteria" xml:space="preserve"><value>Critères de comparaison</value></data>
<data name="lbl.dup.note" xml:space="preserve"><value>Le nom est toujours le critère principal. Cochez des critères supplémentaires :</value></data>
<data name="chk.dup.size" xml:space="preserve"><value>Même taille</value></data>
<data name="chk.dup.created" xml:space="preserve"><value>Même date de création</value></data>
<data name="chk.dup.modified" xml:space="preserve"><value>Même date de modification</value></data>
<data name="chk.dup.subfolders" xml:space="preserve"><value>Même nombre de sous-dossiers</value></data>
<data name="chk.dup.filecount" xml:space="preserve"><value>Même nombre de fichiers</value></data>
<data name="chk.include.subsites" xml:space="preserve"><value>Inclure les sous-sites</value></data>
<data name="ph.dup.lib" xml:space="preserve"><value>Tous (laisser vide)</value></data>
<data name="btn.run.scan" xml:space="preserve"><value>Lancer l'analyse</value></data>
<data name="btn.open.results" xml:space="preserve"><value>Ouvrir les résultats</value></data>
```
Add these properties inside the `Strings` class in `Strings.Designer.cs` (before the closing `}`):
```csharp
// Phase 3: Storage Tab
public static string chk_per_lib => ResourceManager.GetString("chk.per.lib", resourceCulture) ?? string.Empty;
public static string chk_subsites => ResourceManager.GetString("chk.subsites", resourceCulture) ?? string.Empty;
public static string stor_note => ResourceManager.GetString("stor.note", resourceCulture) ?? string.Empty;
public static string btn_gen_storage => ResourceManager.GetString("btn.gen.storage", resourceCulture) ?? string.Empty;
public static string btn_open_storage => ResourceManager.GetString("btn.open.storage", resourceCulture) ?? string.Empty;
public static string stor_col_library => ResourceManager.GetString("stor.col.library", resourceCulture) ?? string.Empty;
public static string stor_col_site => ResourceManager.GetString("stor.col.site", resourceCulture) ?? string.Empty;
public static string stor_col_files => ResourceManager.GetString("stor.col.files", resourceCulture) ?? string.Empty;
public static string stor_col_size => ResourceManager.GetString("stor.col.size", resourceCulture) ?? string.Empty;
public static string stor_col_versions => ResourceManager.GetString("stor.col.versions", resourceCulture) ?? string.Empty;
public static string stor_col_lastmod => ResourceManager.GetString("stor.col.lastmod", resourceCulture) ?? string.Empty;
public static string stor_col_share => ResourceManager.GetString("stor.col.share", resourceCulture) ?? string.Empty;
public static string stor_rad_csv => ResourceManager.GetString("stor.rad.csv", resourceCulture) ?? string.Empty;
public static string stor_rad_html => ResourceManager.GetString("stor.rad.html", resourceCulture) ?? string.Empty;
// Phase 3: File Search Tab
public static string grp_search_filters => ResourceManager.GetString("grp.search.filters", resourceCulture) ?? string.Empty;
public static string lbl_extensions => ResourceManager.GetString("lbl.extensions", resourceCulture) ?? string.Empty;
public static string ph_extensions => ResourceManager.GetString("ph.extensions", resourceCulture) ?? string.Empty;
public static string lbl_regex => ResourceManager.GetString("lbl.regex", resourceCulture) ?? string.Empty;
public static string ph_regex => ResourceManager.GetString("ph.regex", resourceCulture) ?? string.Empty;
public static string chk_created_after => ResourceManager.GetString("chk.created.after", resourceCulture) ?? string.Empty;
public static string chk_created_before => ResourceManager.GetString("chk.created.before", resourceCulture) ?? string.Empty;
public static string chk_modified_after => ResourceManager.GetString("chk.modified.after", resourceCulture) ?? string.Empty;
public static string chk_modified_before => ResourceManager.GetString("chk.modified.before", resourceCulture) ?? string.Empty;
public static string lbl_created_by => ResourceManager.GetString("lbl.created.by", resourceCulture) ?? string.Empty;
public static string ph_created_by => ResourceManager.GetString("ph.created.by", resourceCulture) ?? string.Empty;
public static string lbl_modified_by => ResourceManager.GetString("lbl.modified.by", resourceCulture) ?? string.Empty;
public static string ph_modified_by => ResourceManager.GetString("ph.modified.by", resourceCulture) ?? string.Empty;
public static string lbl_library => ResourceManager.GetString("lbl.library", resourceCulture) ?? string.Empty;
public static string ph_library => ResourceManager.GetString("ph.library", resourceCulture) ?? string.Empty;
public static string lbl_max_results => ResourceManager.GetString("lbl.max.results", resourceCulture) ?? string.Empty;
public static string lbl_site_url => ResourceManager.GetString("lbl.site.url", resourceCulture) ?? string.Empty;
public static string ph_site_url => ResourceManager.GetString("ph.site.url", resourceCulture) ?? string.Empty;
public static string btn_run_search => ResourceManager.GetString("btn.run.search", resourceCulture) ?? string.Empty;
public static string btn_open_search => ResourceManager.GetString("btn.open.search", resourceCulture) ?? string.Empty;
public static string srch_col_name => ResourceManager.GetString("srch.col.name", resourceCulture) ?? string.Empty;
public static string srch_col_ext => ResourceManager.GetString("srch.col.ext", resourceCulture) ?? string.Empty;
public static string srch_col_created => ResourceManager.GetString("srch.col.created", resourceCulture) ?? string.Empty;
public static string srch_col_modified => ResourceManager.GetString("srch.col.modified", resourceCulture) ?? string.Empty;
public static string srch_col_author => ResourceManager.GetString("srch.col.author", resourceCulture) ?? string.Empty;
public static string srch_col_modby => ResourceManager.GetString("srch.col.modby", resourceCulture) ?? string.Empty;
public static string srch_col_size => ResourceManager.GetString("srch.col.size", resourceCulture) ?? string.Empty;
public static string srch_col_path => ResourceManager.GetString("srch.col.path", resourceCulture) ?? string.Empty;
public static string srch_rad_csv => ResourceManager.GetString("srch.rad.csv", resourceCulture) ?? string.Empty;
public static string srch_rad_html => ResourceManager.GetString("srch.rad.html", resourceCulture) ?? string.Empty;
// Phase 3: Duplicates Tab
public static string grp_dup_type => ResourceManager.GetString("grp.dup.type", resourceCulture) ?? string.Empty;
public static string rad_dup_files => ResourceManager.GetString("rad.dup.files", resourceCulture) ?? string.Empty;
public static string rad_dup_folders => ResourceManager.GetString("rad.dup.folders", resourceCulture) ?? string.Empty;
public static string grp_dup_criteria => ResourceManager.GetString("grp.dup.criteria", resourceCulture) ?? string.Empty;
public static string lbl_dup_note => ResourceManager.GetString("lbl.dup.note", resourceCulture) ?? string.Empty;
public static string chk_dup_size => ResourceManager.GetString("chk.dup.size", resourceCulture) ?? string.Empty;
public static string chk_dup_created => ResourceManager.GetString("chk.dup.created", resourceCulture) ?? string.Empty;
public static string chk_dup_modified => ResourceManager.GetString("chk.dup.modified", resourceCulture) ?? string.Empty;
public static string chk_dup_subfolders => ResourceManager.GetString("chk.dup.subfolders", resourceCulture) ?? string.Empty;
public static string chk_dup_filecount => ResourceManager.GetString("chk.dup.filecount", resourceCulture) ?? string.Empty;
public static string chk_include_subsites => ResourceManager.GetString("chk.include.subsites", resourceCulture) ?? string.Empty;
public static string ph_dup_lib => ResourceManager.GetString("ph.dup.lib", resourceCulture) ?? string.Empty;
public static string btn_run_scan => ResourceManager.GetString("btn.run.scan", resourceCulture) ?? string.Empty;
public static string btn_open_results => ResourceManager.GetString("btn.open.results", resourceCulture) ?? string.Empty;
```
**Verification:**
```bash
dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx
```
Expected: 0 errors
## Verification
```bash
dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx
dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj -x 2>&1 | tail -5
```
Expected: 0 build errors; all previously passing tests still pass; no new failures
## Commit Message
feat(03-06): add Phase 3 EN/FR localization keys for Storage, Search, and Duplicates tabs
## Output
After completion, create `.planning/phases/03-storage/03-06-SUMMARY.md`

View File

@@ -0,0 +1,115 @@
---
phase: 03-storage
plan: 06
subsystem: ui
tags: [localization, resx, wpf, csharp, fr, en]
# Dependency graph
requires:
- phase: 03-01
provides: Models, interfaces, and project structure for Phase 3 tabs
provides:
- EN and FR localization keys for Storage tab (14 keys each)
- EN and FR localization keys for File Search tab (26 keys each)
- EN and FR localization keys for Duplicates tab (14 keys each)
- Strongly-typed Strings.Designer.cs accessors for all 54 new keys
affects:
- 03-07 (StorageViewModel/View — binds to storage keys via TranslationSource)
- 03-08 (SearchViewModel + DuplicatesViewModel + Views — binds to search/duplicates keys)
# Tech tracking
tech-stack:
added: []
patterns:
- "Dot-to-underscore key naming: key 'chk.per.lib' becomes accessor 'Strings.chk_per_lib'"
- "Manual Strings.Designer.cs maintenance (no ResXFileCodeGenerator — VS-only tool)"
- "Both .resx files use xml:space='preserve' on each <data> element"
- "New keys appended before </root> with comment block grouping by tab"
key-files:
created: []
modified:
- SharepointToolbox/Localization/Strings.resx
- SharepointToolbox/Localization/Strings.fr.resx
- SharepointToolbox/Localization/Strings.Designer.cs
key-decisions:
- "Pre-existing keys grp.scan.opts, grp.export.fmt, btn.cancel verified present — not duplicated"
- "54 new designer properties follow established dot-to-underscore naming convention"
patterns-established:
- "Phase grouping with XML comments: <!-- Phase 3: Storage Tab -->, <!-- Phase 3: File Search Tab -->, <!-- Phase 3: Duplicates Tab -->"
requirements-completed: [STOR-01, STOR-02, STOR-04, STOR-05, SRCH-01, SRCH-02, SRCH-03, SRCH-04, DUPL-01, DUPL-02, DUPL-03]
# Metrics
duration: 5min
completed: 2026-04-02
---
# Phase 03 Plan 06: Localization — Phase 3 EN and FR Keys Summary
**54 new EN/FR localization keys added across Storage, File Search, and Duplicates tabs with strongly-typed Strings.Designer.cs accessors using dot-to-underscore naming convention**
## Performance
- **Duration:** ~5 min
- **Started:** 2026-04-02T13:27:00Z
- **Completed:** 2026-04-02T13:31:33Z
- **Tasks:** 1
- **Files modified:** 3
## Accomplishments
- Added 14 Storage tab keys in both EN (Strings.resx) and FR (Strings.fr.resx): per-library breakdown, subsites, note, generate/open buttons, 7 column headers, 2 radio buttons
- Added 26 File Search tab keys in both EN and FR: search filters group, extensions/regex/date filters, creator/modifier inputs, library filter, site URL, run/open buttons, 8 column headers, 2 radio buttons
- Added 14 Duplicates tab keys in both EN and FR: duplicate type radio buttons, comparison criteria group, 5 criteria checkboxes, subsites checkbox, library placeholder, run/open buttons
- Added 54 static properties to Strings.Designer.cs following established dot-to-underscore naming convention
- Build verified: 0 errors after all localization changes
## Task Commits
Each task was committed atomically:
1. **Task 1: Add Phase 3 keys to Strings.resx, Strings.fr.resx, and Strings.Designer.cs** - `938de30` (feat)
**Plan metadata:** (to be added by final commit)
## Files Created/Modified
- `SharepointToolbox/Localization/Strings.resx` - 54 new EN data entries for Phase 3 tabs
- `SharepointToolbox/Localization/Strings.fr.resx` - 54 new FR data entries for Phase 3 tabs
- `SharepointToolbox/Localization/Strings.Designer.cs` - 54 new static property accessors
## Decisions Made
None - followed plan as specified. Pre-existing keys verified with git stash/pop workflow to confirm build was clean before changes, and test failures confirmed pre-existing (from export service stubs planned for 03-03/03-05).
## Deviations from Plan
None - plan executed exactly as written.
**Note:** Build had a transient CS1929 error on first invocation (stale compiled artifacts). Second `dotnet build` succeeded with 0 errors. The 9 test failures are pre-existing (export service stubs from plans 03-03/03-05, verified by stashing changes).
## Issues Encountered
- Transient build error CS1929 on first `dotnet build` invocation (stale .NET temp project files). Resolved automatically on second build.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- All Phase 3 localization keys now present — plans 03-07 and 03-08 can use `TranslationSource.Instance["key"]` XAML bindings without missing-key issues
- Wave 3: StorageViewModel/View (03-07) is unblocked
- Wave 4: SearchViewModel + DuplicatesViewModel + Views (03-08) is unblocked
## Self-Check: PASSED
- FOUND: SharepointToolbox/Localization/Strings.resx
- FOUND: SharepointToolbox/Localization/Strings.fr.resx
- FOUND: SharepointToolbox/Localization/Strings.Designer.cs
- FOUND: .planning/phases/03-storage/03-06-SUMMARY.md
- FOUND: commit 938de30
---
*Phase: 03-storage*
*Completed: 2026-04-02*

View File

@@ -0,0 +1,577 @@
---
phase: 03
plan: 07
title: StorageViewModel + StorageView XAML + DI Wiring
status: pending
wave: 3
depends_on:
- 03-03
- 03-06
files_modified:
- SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs
- SharepointToolbox/Views/Tabs/StorageView.xaml
- SharepointToolbox/Views/Tabs/StorageView.xaml.cs
- SharepointToolbox/App.xaml.cs
- SharepointToolbox/MainWindow.xaml
- SharepointToolbox/MainWindow.xaml.cs
autonomous: true
requirements:
- STOR-01
- STOR-02
- STOR-03
- STOR-04
- STOR-05
must_haves:
truths:
- "StorageView appears in the Storage tab (replaces FeatureTabBase stub) when the app runs"
- "User can enter a site URL, set folder depth (0 = library root, or N levels), check per-library breakdown, and click Generate Metrics"
- "DataGrid displays StorageNode rows with library name indented by IndentLevel, file count, total size, version size, last modified"
- "Export buttons are enabled after a successful scan and disabled when Results is empty"
- "Never modify ObservableCollection from a background thread — accumulate in List<T> on background, then Dispatcher.InvokeAsync"
- "StorageViewModel never stores ClientContext — it calls ISessionManager.GetOrCreateContextAsync at operation start"
artifacts:
- path: "SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs"
provides: "Storage tab ViewModel (IStorageService orchestration)"
exports: ["StorageViewModel"]
- path: "SharepointToolbox/Views/Tabs/StorageView.xaml"
provides: "Storage tab XAML (DataGrid + controls)"
- path: "SharepointToolbox/Views/Tabs/StorageView.xaml.cs"
provides: "StorageView code-behind"
key_links:
- from: "StorageViewModel.cs"
to: "IStorageService.CollectStorageAsync"
via: "RunOperationAsync override"
pattern: "CollectStorageAsync"
- from: "StorageViewModel.cs"
to: "ISessionManager.GetOrCreateContextAsync"
via: "context acquisition"
pattern: "GetOrCreateContextAsync"
- from: "StorageView.xaml"
to: "StorageViewModel.Results"
via: "DataGrid ItemsSource binding"
pattern: "Results"
---
# Plan 03-07: StorageViewModel + StorageView XAML + DI Wiring
## Goal
Create the `StorageViewModel` (orchestrates `IStorageService`, export commands) and `StorageView` XAML (DataGrid with IndentLevel-based name indentation). Wire the Storage tab in `MainWindow` to replace the `FeatureTabBase` stub, register all dependencies in `App.xaml.cs`.
## Context
Plans 03-02 (StorageService), 03-03 (export services), and 03-06 (localization) must complete before this plan. The ViewModel follows the exact pattern from `PermissionsViewModel`: `FeatureViewModelBase` base class, `AsyncRelayCommand` for exports, `ObservableCollection` updated via `Dispatcher.InvokeAsync` from background thread.
`MainWindow.xaml` currently has the Storage tab as:
```xml
<TabItem Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.storage]}">
<controls:FeatureTabBase />
</TabItem>
```
This plan adds `x:Name="StorageTabItem"` to that TabItem and wires `StorageTabItem.Content` in `MainWindow.xaml.cs`.
The `IndentConverter` value converter maps `IndentLevel` (int) → `Thickness(IndentLevel * 16, 0, 0, 0)`. It must be defined in the View or a shared Resources file.
## Tasks
### Task 1: Create StorageViewModel
**File:** `SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs`
**Action:** Create
**Why:** Storage tab business logic — orchestrates StorageService scan, holds results, triggers exports.
```csharp
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using SharepointToolbox.Core.Messages;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using SharepointToolbox.Services.Export;
namespace SharepointToolbox.ViewModels.Tabs;
public partial class StorageViewModel : FeatureViewModelBase
{
private readonly IStorageService _storageService;
private readonly ISessionManager _sessionManager;
private readonly StorageCsvExportService _csvExportService;
private readonly StorageHtmlExportService _htmlExportService;
private readonly ILogger<FeatureViewModelBase> _logger;
private TenantProfile? _currentProfile;
[ObservableProperty]
private string _siteUrl = string.Empty;
[ObservableProperty]
private bool _perLibrary = true;
[ObservableProperty]
private bool _includeSubsites;
[ObservableProperty]
private int _folderDepth;
public bool IsMaxDepth
{
get => FolderDepth >= 999;
set
{
if (value) FolderDepth = 999;
else if (FolderDepth >= 999) FolderDepth = 0;
OnPropertyChanged();
}
}
private ObservableCollection<StorageNode> _results = new();
public ObservableCollection<StorageNode> Results
{
get => _results;
private set
{
_results = value;
OnPropertyChanged();
ExportCsvCommand.NotifyCanExecuteChanged();
ExportHtmlCommand.NotifyCanExecuteChanged();
}
}
public IAsyncRelayCommand ExportCsvCommand { get; }
public IAsyncRelayCommand ExportHtmlCommand { get; }
public TenantProfile? CurrentProfile => _currentProfile;
public StorageViewModel(
IStorageService storageService,
ISessionManager sessionManager,
StorageCsvExportService csvExportService,
StorageHtmlExportService htmlExportService,
ILogger<FeatureViewModelBase> logger)
: base(logger)
{
_storageService = storageService;
_sessionManager = sessionManager;
_csvExportService = csvExportService;
_htmlExportService = htmlExportService;
_logger = logger;
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
}
/// <summary>Test constructor — omits export services.</summary>
internal StorageViewModel(
IStorageService storageService,
ISessionManager sessionManager,
ILogger<FeatureViewModelBase> logger)
: base(logger)
{
_storageService = storageService;
_sessionManager = sessionManager;
_csvExportService = null!;
_htmlExportService = null!;
_logger = logger;
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
}
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
{
if (_currentProfile == null)
{
StatusMessage = "No tenant selected. Please connect to a tenant first.";
return;
}
if (string.IsNullOrWhiteSpace(SiteUrl))
{
StatusMessage = "Please enter a site URL.";
return;
}
var ctx = await _sessionManager.GetOrCreateContextAsync(_currentProfile, ct);
// Override URL to the site URL the user entered (may differ from tenant root)
ctx.Url = SiteUrl.TrimEnd('/');
var options = new StorageScanOptions(
PerLibrary: PerLibrary,
IncludeSubsites: IncludeSubsites,
FolderDepth: FolderDepth);
var nodes = await _storageService.CollectStorageAsync(ctx, options, progress, ct);
// Flatten tree to one level for DataGrid display (assign IndentLevel during flatten)
var flat = new List<StorageNode>();
foreach (var node in nodes)
FlattenNode(node, 0, flat);
if (Application.Current?.Dispatcher is { } dispatcher)
{
await dispatcher.InvokeAsync(() =>
{
Results = new ObservableCollection<StorageNode>(flat);
});
}
else
{
Results = new ObservableCollection<StorageNode>(flat);
}
}
protected override void OnTenantSwitched(TenantProfile profile)
{
_currentProfile = profile;
Results = new ObservableCollection<StorageNode>();
SiteUrl = string.Empty;
OnPropertyChanged(nameof(CurrentProfile));
ExportCsvCommand.NotifyCanExecuteChanged();
ExportHtmlCommand.NotifyCanExecuteChanged();
}
internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile;
internal Task TestRunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
=> RunOperationAsync(ct, progress);
private bool CanExport() => Results.Count > 0;
private async Task ExportCsvAsync()
{
if (Results.Count == 0) return;
var dialog = new SaveFileDialog
{
Title = "Export storage metrics to CSV",
Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*",
DefaultExt = "csv",
FileName = "storage_metrics"
};
if (dialog.ShowDialog() != true) return;
try
{
await _csvExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
OpenFile(dialog.FileName);
}
catch (Exception ex)
{
StatusMessage = $"Export failed: {ex.Message}";
_logger.LogError(ex, "CSV export failed.");
}
}
private async Task ExportHtmlAsync()
{
if (Results.Count == 0) return;
var dialog = new SaveFileDialog
{
Title = "Export storage metrics to HTML",
Filter = "HTML files (*.html)|*.html|All files (*.*)|*.*",
DefaultExt = "html",
FileName = "storage_metrics"
};
if (dialog.ShowDialog() != true) return;
try
{
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
OpenFile(dialog.FileName);
}
catch (Exception ex)
{
StatusMessage = $"Export failed: {ex.Message}";
_logger.LogError(ex, "HTML export failed.");
}
}
private static void FlattenNode(StorageNode node, int level, List<StorageNode> result)
{
node.IndentLevel = level;
result.Add(node);
foreach (var child in node.Children)
FlattenNode(child, level + 1, result);
}
private static void OpenFile(string filePath)
{
try { Process.Start(new ProcessStartInfo(filePath) { UseShellExecute = true }); }
catch { /* ignore — file may open but this is best-effort */ }
}
}
```
**Verification:**
```bash
dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx
```
Expected: 0 errors
### Task 2: Create StorageView XAML + code-behind, update DI and MainWindow wiring
**Files:**
- `SharepointToolbox/Views/Tabs/StorageView.xaml`
- `SharepointToolbox/Views/Tabs/StorageView.xaml.cs`
- `SharepointToolbox/Views/Converters/IndentConverter.cs` (create — also adds BytesConverter and InverseBoolConverter)
- `SharepointToolbox/App.xaml` (modify — register converters as Application.Resources)
- `SharepointToolbox/App.xaml.cs` (modify — add Storage registrations)
- `SharepointToolbox/MainWindow.xaml` (modify — add x:Name to Storage TabItem)
- `SharepointToolbox/MainWindow.xaml.cs` (modify — wire StorageTabItem.Content)
**Action:** Create / Modify
**Why:** STOR-01/02/03/04/05 — the UI that ties the storage service to user interaction.
```xml
<!-- SharepointToolbox/Views/Tabs/StorageView.xaml -->
<UserControl x:Class="SharepointToolbox.Views.Tabs.StorageView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
xmlns:conv="clr-namespace:SharepointToolbox.Views.Converters">
<UserControl.Resources>
<conv:IndentConverter x:Key="IndentConverter" />
</UserControl.Resources>
<DockPanel LastChildFill="True">
<!-- Options panel -->
<ScrollViewer DockPanel.Dock="Left" Width="240" VerticalScrollBarVisibility="Auto"
Margin="8,8,4,8">
<StackPanel>
<!-- Site URL -->
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.site.url]}" />
<TextBox Text="{Binding SiteUrl, UpdateSourceTrigger=PropertyChanged}"
IsEnabled="{Binding IsRunning, Converter={StaticResource InverseBoolConverter}}"
Height="26" Margin="0,0,0,8"
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[ph.site.url]}" />
<!-- Scan options group -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.scan.opts]}"
Margin="0,0,0,8">
<StackPanel Margin="4">
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.per.lib]}"
IsChecked="{Binding PerLibrary}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.subsites]}"
IsChecked="{Binding IncludeSubsites}" Margin="0,2" />
<StackPanel Orientation="Horizontal" Margin="0,4,0,0">
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.folder.depth]}"
VerticalAlignment="Center" Padding="0,0,4,0" />
<TextBox Text="{Binding FolderDepth, UpdateSourceTrigger=PropertyChanged}"
Width="40" Height="22" VerticalAlignment="Center"
IsEnabled="{Binding IsMaxDepth, Converter={StaticResource InverseBoolConverter}}" />
</StackPanel>
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.max.depth]}"
IsChecked="{Binding IsMaxDepth}" Margin="0,2" />
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.note]}"
TextWrapping="Wrap" FontSize="11" Foreground="#888"
Margin="0,6,0,0" />
</StackPanel>
</GroupBox>
<!-- Action buttons -->
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.gen.storage]}"
Command="{Binding RunCommand}"
Height="28" Margin="0,0,0,4" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
Command="{Binding CancelCommand}"
Height="28" Margin="0,0,0,8" />
<!-- Export group -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.export.fmt]}"
Margin="0,0,0,8">
<StackPanel Margin="4">
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.rad.csv]}"
Command="{Binding ExportCsvCommand}"
Height="26" Margin="0,2" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.rad.html]}"
Command="{Binding ExportHtmlCommand}"
Height="26" Margin="0,2" />
</StackPanel>
</GroupBox>
<!-- Status -->
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap"
FontSize="11" Foreground="#555" Margin="0,4" />
</StackPanel>
</ScrollViewer>
<!-- Results DataGrid -->
<DataGrid x:Name="ResultsGrid"
ItemsSource="{Binding Results}"
IsReadOnly="True"
AutoGenerateColumns="False"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
Margin="4,8,8,8">
<DataGrid.Columns>
<DataGridTemplateColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.library]}"
Width="*" MinWidth="160">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding Name}"
Margin="{Binding IndentLevel, Converter={StaticResource IndentConverter}}"
VerticalAlignment="Center" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.site]}"
Binding="{Binding SiteTitle}" Width="140" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.files]}"
Binding="{Binding TotalFileCount, StringFormat=N0}"
Width="70" ElementStyle="{StaticResource RightAlignStyle}" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.size]}"
Binding="{Binding TotalSizeBytes, Converter={StaticResource BytesConverter}}"
Width="100" ElementStyle="{StaticResource RightAlignStyle}" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.versions]}"
Binding="{Binding VersionSizeBytes, Converter={StaticResource BytesConverter}}"
Width="110" ElementStyle="{StaticResource RightAlignStyle}" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.lastmod]}"
Binding="{Binding LastModified, StringFormat=yyyy-MM-dd}"
Width="110" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</UserControl>
```
```csharp
// SharepointToolbox/Views/Tabs/StorageView.xaml.cs
using System.Windows.Controls;
namespace SharepointToolbox.Views.Tabs;
public partial class StorageView : UserControl
{
public StorageView(ViewModels.Tabs.StorageViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
}
}
```
The XAML references three resource converters. Create all three in a single file:
```csharp
// SharepointToolbox/Views/Converters/IndentConverter.cs
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace SharepointToolbox.Views.Converters;
/// <summary>Converts IndentLevel (int) to WPF Thickness for DataGrid indent.</summary>
[ValueConversion(typeof(int), typeof(Thickness))]
public class IndentConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
int level = value is int i ? i : 0;
return new Thickness(level * 16, 0, 0, 0);
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotImplementedException();
}
/// <summary>Converts byte count (long) to human-readable size string.</summary>
[ValueConversion(typeof(long), typeof(string))]
public class BytesConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
long bytes = value is long l ? l : 0L;
if (bytes >= 1_073_741_824L) return $"{bytes / 1_073_741_824.0:F2} GB";
if (bytes >= 1_048_576L) return $"{bytes / 1_048_576.0:F2} MB";
if (bytes >= 1024L) return $"{bytes / 1024.0:F2} KB";
return $"{bytes} B";
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotImplementedException();
}
/// <summary>Inverts a bool binding — used to disable controls while an operation is running.</summary>
[ValueConversion(typeof(bool), typeof(bool))]
public class InverseBoolConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> value is bool b && !b;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> value is bool b && !b;
}
```
Register converters and styles in `App.xaml` `<Application.Resources>`. Check `App.xaml` first — if `InverseBoolConverter` was already added by a previous plan, do not duplicate it. Add whichever of these are missing:
```xml
<conv:IndentConverter x:Key="IndentConverter" />
<conv:BytesConverter x:Key="BytesConverter" />
<conv:InverseBoolConverter x:Key="InverseBoolConverter" />
<Style x:Key="RightAlignStyle" TargetType="TextBlock">
<Setter Property="HorizontalAlignment" Value="Right" />
</Style>
```
Also ensure the `conv` xmlns is declared on the `Application` root element if not already present:
```xml
xmlns:conv="clr-namespace:SharepointToolbox.Views.Converters"
```
In `App.xaml.cs` `ConfigureServices`, add before existing Phase 2 registrations:
```csharp
// Phase 3: Storage
services.AddTransient<IStorageService, StorageService>();
services.AddTransient<StorageCsvExportService>();
services.AddTransient<StorageHtmlExportService>();
services.AddTransient<StorageViewModel>();
services.AddTransient<StorageView>();
```
In `MainWindow.xaml`, change the Storage TabItem from:
```xml
<TabItem Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.storage]}">
<controls:FeatureTabBase />
</TabItem>
```
to:
```xml
<TabItem x:Name="StorageTabItem"
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.storage]}">
</TabItem>
```
In `MainWindow.xaml.cs`, add after the PermissionsTabItem wiring line:
```csharp
StorageTabItem.Content = serviceProvider.GetRequiredService<StorageView>();
```
**Verification:**
```bash
dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx
dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj -x 2>&1 | tail -5
```
Expected: 0 build errors; all tests pass
## Verification
```bash
dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx
```
Expected: 0 errors. StorageView wired in MainWindow (grep: `StorageTabItem.Content`). StorageService registered in DI (grep: `IStorageService, StorageService`). `InverseBoolConverter` registered in App.xaml resources (grep: `InverseBoolConverter`).
## Commit Message
feat(03-07): create StorageViewModel, StorageView XAML, DI registration, and MainWindow wiring
## Output
After completion, create `.planning/phases/03-storage/03-07-SUMMARY.md`

View File

@@ -0,0 +1,152 @@
---
phase: 03-storage
plan: 07
subsystem: ui
tags: [wpf, mvvm, datagrid, ivalueconverter, di, storage, xaml]
# Dependency graph
requires:
- phase: 03-storage plan 03-02
provides: IStorageService/StorageService — storage scan engine
- phase: 03-storage plan 03-03
provides: StorageCsvExportService, StorageHtmlExportService
- phase: 03-storage plan 03-06
provides: localization keys for Storage tab UI
provides:
- StorageViewModel: IStorageService orchestration with FlattenNode, export commands, tenant-switching
- StorageView.xaml: DataGrid with IndentLevel-based Thickness margin for tree-indent display
- StorageView.xaml.cs: code-behind wiring DataContext
- IndentConverter, BytesConverter, InverseBoolConverter registered in Application.Resources
- RightAlignStyle registered in Application.Resources
- Storage tab wired in MainWindow via DI-resolved StorageView
affects: [03-08, phase-04-teams]
# Tech tracking
tech-stack:
added: []
patterns:
- StorageViewModel uses FeatureViewModelBase + AsyncRelayCommand (same as PermissionsViewModel)
- TenantProfile site override via new profile with site URL (ClientContext.Url is read-only)
- IValueConverter triple registration in App.xaml: IndentConverter/BytesConverter/InverseBoolConverter
- FlattenNode recursive helper assigns IndentLevel pre-Dispatcher.InvokeAsync
key-files:
created:
- SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs
- SharepointToolbox/Views/Tabs/StorageView.xaml
- SharepointToolbox/Views/Tabs/StorageView.xaml.cs
- SharepointToolbox/Views/Converters/IndentConverter.cs
modified:
- SharepointToolbox/App.xaml
- SharepointToolbox/App.xaml.cs
- SharepointToolbox/MainWindow.xaml
- SharepointToolbox/MainWindow.xaml.cs
- SharepointToolbox/Services/Export/SearchCsvExportService.cs
- SharepointToolbox/Services/Export/SearchHtmlExportService.cs
key-decisions:
- "ClientContext.Url is read-only in CSOM — must create new TenantProfile with site URL for GetOrCreateContextAsync (same approach as PermissionsViewModel)"
- "IndentConverter/BytesConverter/InverseBoolConverter registered in App.xaml Application.Resources — accessible to all views without per-UserControl declaration"
- "StorageView XAML omits local UserControl.Resources converter declarations — uses Application-level StaticResource references instead"
patterns-established:
- "Site-scoped operations create new TenantProfile{TenantUrl=siteUrl, ClientId/Name from current profile}"
- "FlattenNode pre-assigns IndentLevel before Dispatcher.InvokeAsync to avoid cross-thread collection mutation"
requirements-completed: [STOR-01, STOR-02, STOR-03, STOR-04, STOR-05]
# Metrics
duration: 4min
completed: 2026-04-02
---
# Phase 03 Plan 07: StorageViewModel + StorageView XAML + DI Wiring Summary
**StorageViewModel orchestrating IStorageService via FeatureViewModelBase + StorageView DataGrid with IndentConverter-based tree indentation, fully wired through DI in MainWindow**
## Performance
- **Duration:** ~4 min
- **Started:** 2026-04-02T13:35:02Z
- **Completed:** 2026-04-02T13:39:00Z
- **Tasks:** 2
- **Files modified:** 10
## Accomplishments
- StorageViewModel created with RunOperationAsync → IStorageService.CollectStorageAsync, FlattenNode tree-flattening, Dispatcher.InvokeAsync-safe ObservableCollection update
- StorageView.xaml DataGrid with IndentLevel-driven Thickness margin, BytesConverter for human-readable sizes, all scan/export controls bound to ViewModel
- IndentConverter, BytesConverter, InverseBoolConverter, and RightAlignStyle registered in App.xaml Application.Resources
- Storage tab live in MainWindow via DI-resolved StorageView (same pattern as Permissions tab)
## Task Commits
Each task was committed atomically:
1. **Task 1: Create StorageViewModel** - `e174a18` (feat)
2. **Task 2: Create StorageView XAML + code-behind, update DI and MainWindow wiring** - `e08452d` (feat)
## Files Created/Modified
- `SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs` - Storage tab ViewModel (IStorageService orchestration, export commands, tenant-switching)
- `SharepointToolbox/Views/Tabs/StorageView.xaml` - Storage tab XAML (DataGrid + scan controls + export buttons)
- `SharepointToolbox/Views/Tabs/StorageView.xaml.cs` - Code-behind wiring DataContext to StorageViewModel
- `SharepointToolbox/Views/Converters/IndentConverter.cs` - IndentConverter, BytesConverter, InverseBoolConverter in one file
- `SharepointToolbox/App.xaml` - Registered three converters and RightAlignStyle in Application.Resources
- `SharepointToolbox/App.xaml.cs` - Phase 3 Storage DI registrations (IStorageService, exports, VM, View)
- `SharepointToolbox/MainWindow.xaml` - Added x:Name=StorageTabItem to Storage TabItem
- `SharepointToolbox/MainWindow.xaml.cs` - Wired StorageTabItem.Content from DI
- `SharepointToolbox/Services/Export/SearchCsvExportService.cs` - Added missing System.IO using
- `SharepointToolbox/Services/Export/SearchHtmlExportService.cs` - Added missing System.IO using
## Decisions Made
- `ClientContext.Url` is read-only in CSOM — the site URL override is done by creating a new `TenantProfile` with `TenantUrl = SiteUrl` (same ClientId/Name from current profile), passed to `GetOrCreateContextAsync`.
- All three converters (IndentConverter, BytesConverter, InverseBoolConverter) registered at Application scope in App.xaml rather than per-view, avoiding duplicate resource key definitions.
- `StorageView.xaml` omits local `UserControl.Resources` declarations for converters — references Application-level `StaticResource` instead, keeping the XAML clean.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Fixed ClientContext.Url read-only assignment in StorageViewModel.RunOperationAsync**
- **Found during:** Task 1 (StorageViewModel creation)
- **Issue:** Plan included `ctx.Url = SiteUrl.TrimEnd('/')` but `ClientRuntimeContext.Url` is a read-only property in CSOM
- **Fix:** Created a new `TenantProfile{TenantUrl=siteUrl, ClientId, Name}` and passed it to `GetOrCreateContextAsync` — the context is keyed by URL so it gets or creates the right session
- **Files modified:** `SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs`
- **Verification:** Build succeeded with 0 errors
- **Committed in:** `e174a18` (Task 1 commit)
**2. [Rule 3 - Blocking] Added missing System.IO using to SearchCsvExportService and SearchHtmlExportService**
- **Found during:** Task 1 build verification
- **Issue:** Both Search export services used `File.WriteAllTextAsync` without `using System.IO;` — same established project convention (WPF project does not include System.IO in implicit usings)
- **Fix:** Added `using System.IO;` to both files
- **Files modified:** `SharepointToolbox/Services/Export/SearchCsvExportService.cs`, `SharepointToolbox/Services/Export/SearchHtmlExportService.cs`
- **Verification:** Build succeeded with 0 errors; 82 tests pass
- **Committed in:** `e174a18` (Task 1 commit)
---
**Total deviations:** 2 auto-fixed (1 bug, 1 blocking)
**Impact on plan:** Both auto-fixes necessary for correctness. No scope creep.
## Issues Encountered
None beyond the two auto-fixed deviations above.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- StorageView is live and functional — users can enter site URL, configure scan options, run scan, and export results
- Plans 03-03 (StorageCsvExportService) and 03-06 (localization keys) are prerequisites and were already completed
- Ready for Wave 4: Plan 03-08 (SearchViewModel + DuplicatesViewModel + Views + visual checkpoint)
- All 82 tests passing, 10 expected skips (CSOM live-connection tests)
---
*Phase: 03-storage*
*Completed: 2026-04-02*

View File

@@ -0,0 +1,792 @@
---
phase: 03
plan: 08
title: SearchViewModel + SearchView + DuplicatesViewModel + DuplicatesView + DI Wiring + Visual Checkpoint
status: pending
wave: 4
depends_on:
- 03-05
- 03-06
- 03-07
files_modified:
- SharepointToolbox/ViewModels/Tabs/SearchViewModel.cs
- SharepointToolbox/Views/Tabs/SearchView.xaml
- SharepointToolbox/Views/Tabs/SearchView.xaml.cs
- SharepointToolbox/ViewModels/Tabs/DuplicatesViewModel.cs
- SharepointToolbox/Views/Tabs/DuplicatesView.xaml
- SharepointToolbox/Views/Tabs/DuplicatesView.xaml.cs
- SharepointToolbox/App.xaml.cs
- SharepointToolbox/MainWindow.xaml
- SharepointToolbox/MainWindow.xaml.cs
autonomous: false
requirements:
- SRCH-01
- SRCH-02
- SRCH-03
- SRCH-04
- DUPL-01
- DUPL-02
- DUPL-03
must_haves:
truths:
- "File Search tab shows filter controls (extensions, regex, date pickers, creator, editor, library, max results, site URL)"
- "Running a file search populates the DataGrid with file name, extension, created, modified, author, modifier, size columns"
- "Export CSV and Export HTML buttons are enabled after a successful search, disabled when results are empty"
- "Duplicates tab shows type selector (Files/Folders), criteria checkboxes, site URL, optional library field, and Run Scan button"
- "Running a duplicate scan populates the DataGrid with one row per DuplicateItem across all groups"
- "Export HTML button is enabled after scan with results"
- "All three feature tabs (Storage, File Search, Duplicates) are visible and functional in the running application"
artifacts:
- path: "SharepointToolbox/ViewModels/Tabs/SearchViewModel.cs"
provides: "File Search tab ViewModel"
exports: ["SearchViewModel"]
- path: "SharepointToolbox/Views/Tabs/SearchView.xaml"
provides: "File Search tab XAML"
- path: "SharepointToolbox/ViewModels/Tabs/DuplicatesViewModel.cs"
provides: "Duplicates tab ViewModel"
exports: ["DuplicatesViewModel"]
- path: "SharepointToolbox/Views/Tabs/DuplicatesView.xaml"
provides: "Duplicates tab XAML"
key_links:
- from: "SearchViewModel.cs"
to: "ISearchService.SearchFilesAsync"
via: "RunOperationAsync override"
pattern: "SearchFilesAsync"
- from: "DuplicatesViewModel.cs"
to: "IDuplicatesService.ScanDuplicatesAsync"
via: "RunOperationAsync override"
pattern: "ScanDuplicatesAsync"
- from: "App.xaml.cs"
to: "ISearchService, SearchService"
via: "DI registration"
pattern: "ISearchService"
- from: "App.xaml.cs"
to: "IDuplicatesService, DuplicatesService"
via: "DI registration"
pattern: "IDuplicatesService"
---
# Plan 03-08: SearchViewModel + SearchView + DuplicatesViewModel + DuplicatesView + DI Wiring + Visual Checkpoint
## Goal
Create ViewModels and XAML Views for the File Search and Duplicates tabs, wire them into `MainWindow`, register all dependencies in `App.xaml.cs`, then pause for a visual checkpoint to verify all three Phase 3 tabs (Storage, File Search, Duplicates) are visible and functional in the running application.
## Context
Plans 03-05 (export services), 03-06 (localization), and 03-07 (StorageView + DI) must complete first. The pattern established by `StorageViewModel` and `PermissionsViewModel` applies identically: `FeatureViewModelBase`, `AsyncRelayCommand`, `Dispatcher.InvokeAsync` for `ObservableCollection` updates, no stored `ClientContext`.
The Duplicates DataGrid flattens `DuplicateGroup.Items` into a flat list for display. Each row shows the group name, the individual item path, library, size, dates. A `GroupName` property on a display wrapper DTO is used to identify the group.
`InverseBoolConverter`, `BytesConverter`, and `RightAlignStyle` are registered in `App.xaml` by Plan 03-07. Both Search and Duplicates views use `{StaticResource InverseBoolConverter}` and `{StaticResource BytesConverter}` — these will resolve from `Application.Resources`.
## Tasks
### Task 1a: Create SearchViewModel, SearchView XAML, and SearchView code-behind
**Files:**
- `SharepointToolbox/ViewModels/Tabs/SearchViewModel.cs`
- `SharepointToolbox/Views/Tabs/SearchView.xaml`
- `SharepointToolbox/Views/Tabs/SearchView.xaml.cs`
**Action:** Create
**Why:** SRCH-01 through SRCH-04 — the UI layer for file search.
```csharp
// SharepointToolbox/ViewModels/Tabs/SearchViewModel.cs
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using SharepointToolbox.Services.Export;
namespace SharepointToolbox.ViewModels.Tabs;
public partial class SearchViewModel : FeatureViewModelBase
{
private readonly ISearchService _searchService;
private readonly ISessionManager _sessionManager;
private readonly SearchCsvExportService _csvExportService;
private readonly SearchHtmlExportService _htmlExportService;
private readonly ILogger<FeatureViewModelBase> _logger;
private TenantProfile? _currentProfile;
// ── Filter observable properties ─────────────────────────────────────────
[ObservableProperty] private string _siteUrl = string.Empty;
[ObservableProperty] private string _extensions = string.Empty;
[ObservableProperty] private string _regex = string.Empty;
[ObservableProperty] private bool _useCreatedAfter;
[ObservableProperty] private DateTime _createdAfter = DateTime.Today.AddMonths(-1);
[ObservableProperty] private bool _useCreatedBefore;
[ObservableProperty] private DateTime _createdBefore = DateTime.Today;
[ObservableProperty] private bool _useModifiedAfter;
[ObservableProperty] private DateTime _modifiedAfter = DateTime.Today.AddMonths(-1);
[ObservableProperty] private bool _useModifiedBefore;
[ObservableProperty] private DateTime _modifiedBefore = DateTime.Today;
[ObservableProperty] private string _createdBy = string.Empty;
[ObservableProperty] private string _modifiedBy = string.Empty;
[ObservableProperty] private string _library = string.Empty;
[ObservableProperty] private int _maxResults = 5000;
private ObservableCollection<SearchResult> _results = new();
public ObservableCollection<SearchResult> Results
{
get => _results;
private set
{
_results = value;
OnPropertyChanged();
ExportCsvCommand.NotifyCanExecuteChanged();
ExportHtmlCommand.NotifyCanExecuteChanged();
}
}
public IAsyncRelayCommand ExportCsvCommand { get; }
public IAsyncRelayCommand ExportHtmlCommand { get; }
public TenantProfile? CurrentProfile => _currentProfile;
public SearchViewModel(
ISearchService searchService,
ISessionManager sessionManager,
SearchCsvExportService csvExportService,
SearchHtmlExportService htmlExportService,
ILogger<FeatureViewModelBase> logger)
: base(logger)
{
_searchService = searchService;
_sessionManager = sessionManager;
_csvExportService = csvExportService;
_htmlExportService = htmlExportService;
_logger = logger;
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
}
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
{
if (_currentProfile == null)
{
StatusMessage = "No tenant selected. Please connect to a tenant first.";
return;
}
if (string.IsNullOrWhiteSpace(SiteUrl))
{
StatusMessage = "Please enter a site URL.";
return;
}
var ctx = await _sessionManager.GetOrCreateContextAsync(_currentProfile, ct);
ctx.Url = SiteUrl.TrimEnd('/');
var opts = new SearchOptions(
Extensions: ParseExtensions(Extensions),
Regex: string.IsNullOrWhiteSpace(Regex) ? null : Regex,
CreatedAfter: UseCreatedAfter ? CreatedAfter : null,
CreatedBefore: UseCreatedBefore ? CreatedBefore : null,
ModifiedAfter: UseModifiedAfter ? ModifiedAfter : null,
ModifiedBefore: UseModifiedBefore ? ModifiedBefore : null,
CreatedBy: string.IsNullOrWhiteSpace(CreatedBy) ? null : CreatedBy,
ModifiedBy: string.IsNullOrWhiteSpace(ModifiedBy) ? null : ModifiedBy,
Library: string.IsNullOrWhiteSpace(Library) ? null : Library,
MaxResults: Math.Clamp(MaxResults, 1, 50_000),
SiteUrl: SiteUrl.TrimEnd('/')
);
var items = await _searchService.SearchFilesAsync(ctx, opts, progress, ct);
if (Application.Current?.Dispatcher is { } dispatcher)
await dispatcher.InvokeAsync(() => Results = new ObservableCollection<SearchResult>(items));
else
Results = new ObservableCollection<SearchResult>(items);
}
protected override void OnTenantSwitched(Core.Models.TenantProfile profile)
{
_currentProfile = profile;
Results = new ObservableCollection<SearchResult>();
SiteUrl = string.Empty;
OnPropertyChanged(nameof(CurrentProfile));
ExportCsvCommand.NotifyCanExecuteChanged();
ExportHtmlCommand.NotifyCanExecuteChanged();
}
internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile;
private bool CanExport() => Results.Count > 0;
private async Task ExportCsvAsync()
{
if (Results.Count == 0) return;
var dialog = new SaveFileDialog
{
Title = "Export search results to CSV",
Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*",
DefaultExt = "csv",
FileName = "search_results"
};
if (dialog.ShowDialog() != true) return;
try
{
await _csvExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
OpenFile(dialog.FileName);
}
catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "CSV export failed."); }
}
private async Task ExportHtmlAsync()
{
if (Results.Count == 0) return;
var dialog = new SaveFileDialog
{
Title = "Export search results to HTML",
Filter = "HTML files (*.html)|*.html|All files (*.*)|*.*",
DefaultExt = "html",
FileName = "search_results"
};
if (dialog.ShowDialog() != true) return;
try
{
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
OpenFile(dialog.FileName);
}
catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "HTML export failed."); }
}
private static string[] ParseExtensions(string input)
{
if (string.IsNullOrWhiteSpace(input)) return Array.Empty<string>();
return input.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries)
.Select(e => e.TrimStart('.').ToLowerInvariant())
.Where(e => e.Length > 0)
.Distinct()
.ToArray();
}
private static void OpenFile(string filePath)
{
try { Process.Start(new ProcessStartInfo(filePath) { UseShellExecute = true }); }
catch { }
}
}
```
```xml
<!-- SharepointToolbox/Views/Tabs/SearchView.xaml -->
<UserControl x:Class="SharepointToolbox.Views.Tabs.SearchView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:SharepointToolbox.Localization">
<DockPanel LastChildFill="True">
<!-- Filters panel -->
<ScrollViewer DockPanel.Dock="Left" Width="260" VerticalScrollBarVisibility="Auto" Margin="8,8,4,8">
<StackPanel>
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.site.url]}" />
<TextBox Text="{Binding SiteUrl, UpdateSourceTrigger=PropertyChanged}"
IsEnabled="{Binding IsRunning, Converter={StaticResource InverseBoolConverter}}"
Height="26" Margin="0,0,0,8" />
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.search.filters]}"
Margin="0,0,0,8">
<StackPanel Margin="4">
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.extensions]}" Padding="0,0,0,2" />
<TextBox Text="{Binding Extensions, UpdateSourceTrigger=PropertyChanged}" Height="26"
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[ph.extensions]}" Margin="0,0,0,6" />
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.regex]}" Padding="0,0,0,2" />
<TextBox Text="{Binding Regex, UpdateSourceTrigger=PropertyChanged}" Height="26"
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[ph.regex]}" Margin="0,0,0,6" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.created.after]}"
IsChecked="{Binding UseCreatedAfter}" Margin="0,2" />
<DatePicker SelectedDate="{Binding CreatedAfter}"
IsEnabled="{Binding UseCreatedAfter}" Height="26" Margin="0,0,0,4" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.created.before]}"
IsChecked="{Binding UseCreatedBefore}" Margin="0,2" />
<DatePicker SelectedDate="{Binding CreatedBefore}"
IsEnabled="{Binding UseCreatedBefore}" Height="26" Margin="0,0,0,4" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.modified.after]}"
IsChecked="{Binding UseModifiedAfter}" Margin="0,2" />
<DatePicker SelectedDate="{Binding ModifiedAfter}"
IsEnabled="{Binding UseModifiedAfter}" Height="26" Margin="0,0,0,4" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.modified.before]}"
IsChecked="{Binding UseModifiedBefore}" Margin="0,2" />
<DatePicker SelectedDate="{Binding ModifiedBefore}"
IsEnabled="{Binding UseModifiedBefore}" Height="26" Margin="0,0,0,4" />
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.created.by]}" Padding="0,0,0,2" />
<TextBox Text="{Binding CreatedBy, UpdateSourceTrigger=PropertyChanged}" Height="26"
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[ph.created.by]}" Margin="0,0,0,6" />
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.modified.by]}" Padding="0,0,0,2" />
<TextBox Text="{Binding ModifiedBy, UpdateSourceTrigger=PropertyChanged}" Height="26"
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[ph.modified.by]}" Margin="0,0,0,6" />
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.library]}" Padding="0,0,0,2" />
<TextBox Text="{Binding Library, UpdateSourceTrigger=PropertyChanged}" Height="26"
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[ph.library]}" Margin="0,0,0,6" />
<StackPanel Orientation="Horizontal" Margin="0,4,0,0">
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.max.results]}"
VerticalAlignment="Center" Padding="0,0,4,0" />
<TextBox Text="{Binding MaxResults, UpdateSourceTrigger=PropertyChanged}"
Width="60" Height="22" VerticalAlignment="Center" />
</StackPanel>
</StackPanel>
</GroupBox>
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.run.search]}"
Command="{Binding RunCommand}" Height="28" Margin="0,0,0,4" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
Command="{Binding CancelCommand}" Height="28" Margin="0,0,0,8" />
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.export.fmt]}" Margin="0,0,0,8">
<StackPanel Margin="4">
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[srch.rad.csv]}"
Command="{Binding ExportCsvCommand}" Height="26" Margin="0,2" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[srch.rad.html]}"
Command="{Binding ExportHtmlCommand}" Height="26" Margin="0,2" />
</StackPanel>
</GroupBox>
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" FontSize="11" Foreground="#555" Margin="0,4" />
</StackPanel>
</ScrollViewer>
<!-- Results DataGrid -->
<DataGrid ItemsSource="{Binding Results}" IsReadOnly="True" AutoGenerateColumns="False"
VirtualizingPanel.IsVirtualizing="True" Margin="4,8,8,8">
<DataGrid.Columns>
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[srch.col.name]}"
Binding="{Binding Title}" Width="180" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[srch.col.ext]}"
Binding="{Binding FileExtension}" Width="70" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[srch.col.created]}"
Binding="{Binding Created, StringFormat=yyyy-MM-dd}" Width="100" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[srch.col.author]}"
Binding="{Binding Author}" Width="130" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[srch.col.modified]}"
Binding="{Binding LastModified, StringFormat=yyyy-MM-dd}" Width="100" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[srch.col.modby]}"
Binding="{Binding ModifiedBy}" Width="130" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[srch.col.size]}"
Binding="{Binding SizeBytes, Converter={StaticResource BytesConverter}}"
Width="90" ElementStyle="{StaticResource RightAlignStyle}" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[srch.col.path]}"
Binding="{Binding Path}" Width="*" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</UserControl>
```
```csharp
// SharepointToolbox/Views/Tabs/SearchView.xaml.cs
using System.Windows.Controls;
namespace SharepointToolbox.Views.Tabs;
public partial class SearchView : UserControl
{
public SearchView(ViewModels.Tabs.SearchViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
}
}
```
**Verification:**
```bash
dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx
```
Expected: 0 errors
### Task 1b: Create DuplicatesViewModel, DuplicatesView XAML, and DuplicatesView code-behind
**Files:**
- `SharepointToolbox/ViewModels/Tabs/DuplicatesViewModel.cs`
- `SharepointToolbox/Views/Tabs/DuplicatesView.xaml`
- `SharepointToolbox/Views/Tabs/DuplicatesView.xaml.cs`
**Action:** Create
**Why:** DUPL-01 through DUPL-03 — the UI layer for duplicate detection.
```csharp
// SharepointToolbox/ViewModels/Tabs/DuplicatesViewModel.cs
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using SharepointToolbox.Services.Export;
namespace SharepointToolbox.ViewModels.Tabs;
/// <summary>Flat display row wrapping a DuplicateItem with its group name.</summary>
public class DuplicateRow
{
public string GroupName { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Path { get; set; } = string.Empty;
public string Library { get; set; } = string.Empty;
public long? SizeBytes { get; set; }
public DateTime? Created { get; set; }
public DateTime? Modified { get; set; }
public int? FolderCount { get; set; }
public int? FileCount { get; set; }
public int GroupSize { get; set; }
}
public partial class DuplicatesViewModel : FeatureViewModelBase
{
private readonly IDuplicatesService _duplicatesService;
private readonly ISessionManager _sessionManager;
private readonly DuplicatesHtmlExportService _htmlExportService;
private readonly ILogger<FeatureViewModelBase> _logger;
private TenantProfile? _currentProfile;
private IReadOnlyList<DuplicateGroup> _lastGroups = Array.Empty<DuplicateGroup>();
[ObservableProperty] private string _siteUrl = string.Empty;
[ObservableProperty] private bool _modeFiles = true;
[ObservableProperty] private bool _modeFolders;
[ObservableProperty] private bool _matchSize = true;
[ObservableProperty] private bool _matchCreated;
[ObservableProperty] private bool _matchModified;
[ObservableProperty] private bool _matchSubfolders;
[ObservableProperty] private bool _matchFileCount;
[ObservableProperty] private bool _includeSubsites;
[ObservableProperty] private string _library = string.Empty;
private ObservableCollection<DuplicateRow> _results = new();
public ObservableCollection<DuplicateRow> Results
{
get => _results;
private set
{
_results = value;
OnPropertyChanged();
ExportHtmlCommand.NotifyCanExecuteChanged();
}
}
public IAsyncRelayCommand ExportHtmlCommand { get; }
public TenantProfile? CurrentProfile => _currentProfile;
public DuplicatesViewModel(
IDuplicatesService duplicatesService,
ISessionManager sessionManager,
DuplicatesHtmlExportService htmlExportService,
ILogger<FeatureViewModelBase> logger)
: base(logger)
{
_duplicatesService = duplicatesService;
_sessionManager = sessionManager;
_htmlExportService = htmlExportService;
_logger = logger;
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
}
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
{
if (_currentProfile == null)
{
StatusMessage = "No tenant selected. Please connect to a tenant first.";
return;
}
if (string.IsNullOrWhiteSpace(SiteUrl))
{
StatusMessage = "Please enter a site URL.";
return;
}
var ctx = await _sessionManager.GetOrCreateContextAsync(_currentProfile, ct);
ctx.Url = SiteUrl.TrimEnd('/');
var opts = new DuplicateScanOptions(
Mode: ModeFiles ? "Files" : "Folders",
MatchSize: MatchSize,
MatchCreated: MatchCreated,
MatchModified: MatchModified,
MatchSubfolderCount: MatchSubfolders,
MatchFileCount: MatchFileCount,
IncludeSubsites: IncludeSubsites,
Library: string.IsNullOrWhiteSpace(Library) ? null : Library
);
var groups = await _duplicatesService.ScanDuplicatesAsync(ctx, opts, progress, ct);
_lastGroups = groups;
// Flatten groups to display rows
var rows = groups
.SelectMany(g => g.Items.Select(item => new DuplicateRow
{
GroupName = g.Name,
Name = item.Name,
Path = item.Path,
Library = item.Library,
SizeBytes = item.SizeBytes,
Created = item.Created,
Modified = item.Modified,
FolderCount = item.FolderCount,
FileCount = item.FileCount,
GroupSize = g.Items.Count
}))
.ToList();
if (Application.Current?.Dispatcher is { } dispatcher)
await dispatcher.InvokeAsync(() => Results = new ObservableCollection<DuplicateRow>(rows));
else
Results = new ObservableCollection<DuplicateRow>(rows);
}
protected override void OnTenantSwitched(Core.Models.TenantProfile profile)
{
_currentProfile = profile;
Results = new ObservableCollection<DuplicateRow>();
_lastGroups = Array.Empty<DuplicateGroup>();
SiteUrl = string.Empty;
OnPropertyChanged(nameof(CurrentProfile));
ExportHtmlCommand.NotifyCanExecuteChanged();
}
internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile;
private bool CanExport() => _lastGroups.Count > 0;
private async Task ExportHtmlAsync()
{
if (_lastGroups.Count == 0) return;
var dialog = new SaveFileDialog
{
Title = "Export duplicates report to HTML",
Filter = "HTML files (*.html)|*.html|All files (*.*)|*.*",
DefaultExt = "html",
FileName = "duplicates_report"
};
if (dialog.ShowDialog() != true) return;
try
{
await _htmlExportService.WriteAsync(_lastGroups, dialog.FileName, CancellationToken.None);
Process.Start(new ProcessStartInfo(dialog.FileName) { UseShellExecute = true });
}
catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "HTML export failed."); }
}
}
```
```xml
<!-- SharepointToolbox/Views/Tabs/DuplicatesView.xaml -->
<UserControl x:Class="SharepointToolbox.Views.Tabs.DuplicatesView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:SharepointToolbox.Localization">
<DockPanel LastChildFill="True">
<!-- Options panel -->
<ScrollViewer DockPanel.Dock="Left" Width="240" VerticalScrollBarVisibility="Auto" Margin="8,8,4,8">
<StackPanel>
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.site.url]}" />
<TextBox Text="{Binding SiteUrl, UpdateSourceTrigger=PropertyChanged}"
IsEnabled="{Binding IsRunning, Converter={StaticResource InverseBoolConverter}}"
Height="26" Margin="0,0,0,8" />
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.dup.type]}" Margin="0,0,0,8">
<StackPanel Margin="4">
<RadioButton Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[rad.dup.files]}"
IsChecked="{Binding ModeFiles}" Margin="0,2" />
<RadioButton Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[rad.dup.folders]}"
IsChecked="{Binding ModeFolders}" Margin="0,2" />
</StackPanel>
</GroupBox>
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.dup.criteria]}" Margin="0,0,0,8">
<StackPanel Margin="4">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.dup.note]}"
TextWrapping="Wrap" FontSize="11" Foreground="#555" Margin="0,0,0,6" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.dup.size]}"
IsChecked="{Binding MatchSize}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.dup.created]}"
IsChecked="{Binding MatchCreated}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.dup.modified]}"
IsChecked="{Binding MatchModified}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.dup.subfolders]}"
IsChecked="{Binding MatchSubfolders}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.dup.filecount]}"
IsChecked="{Binding MatchFileCount}" Margin="0,2" />
</StackPanel>
</GroupBox>
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.library]}" />
<TextBox Text="{Binding Library, UpdateSourceTrigger=PropertyChanged}" Height="26"
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[ph.dup.lib]}" Margin="0,0,0,6" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.include.subsites]}"
IsChecked="{Binding IncludeSubsites}" Margin="0,4,0,8" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.run.scan]}"
Command="{Binding RunCommand}" Height="28" Margin="0,0,0,4" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
Command="{Binding CancelCommand}" Height="28" Margin="0,0,0,8" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.open.results]}"
Command="{Binding ExportHtmlCommand}" Height="28" Margin="0,0,0,4" />
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" FontSize="11" Foreground="#555" Margin="0,4" />
</StackPanel>
</ScrollViewer>
<!-- Results DataGrid -->
<DataGrid ItemsSource="{Binding Results}" IsReadOnly="True" AutoGenerateColumns="False"
VirtualizingPanel.IsVirtualizing="True" Margin="4,8,8,8">
<DataGrid.Columns>
<DataGridTextColumn Header="Group" Binding="{Binding GroupName}" Width="160" />
<DataGridTextColumn Header="Copies" Binding="{Binding GroupSize}" Width="60"
ElementStyle="{StaticResource RightAlignStyle}" />
<DataGridTextColumn Header="Name" Binding="{Binding Name}" Width="160" />
<DataGridTextColumn Header="Library" Binding="{Binding Library}" Width="120" />
<DataGridTextColumn Header="Size"
Binding="{Binding SizeBytes, Converter={StaticResource BytesConverter}}"
Width="90" ElementStyle="{StaticResource RightAlignStyle}" />
<DataGridTextColumn Header="Created" Binding="{Binding Created, StringFormat=yyyy-MM-dd}" Width="100" />
<DataGridTextColumn Header="Modified" Binding="{Binding Modified, StringFormat=yyyy-MM-dd}" Width="100" />
<DataGridTextColumn Header="Path" Binding="{Binding Path}" Width="*" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</UserControl>
```
```csharp
// SharepointToolbox/Views/Tabs/DuplicatesView.xaml.cs
using System.Windows.Controls;
namespace SharepointToolbox.Views.Tabs;
public partial class DuplicatesView : UserControl
{
public DuplicatesView(ViewModels.Tabs.DuplicatesViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
}
}
```
**Verification:**
```bash
dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx
```
Expected: 0 errors
### Task 2: DI registration + MainWindow wiring + visual checkpoint
**Files:**
- `SharepointToolbox/App.xaml.cs` (modify)
- `SharepointToolbox/MainWindow.xaml` (modify)
- `SharepointToolbox/MainWindow.xaml.cs` (modify)
**Action:** Modify
**Why:** Services must be registered; tabs must replace FeatureTabBase stubs; user must verify all three Phase 3 tabs are visible and functional.
In `App.xaml.cs` `ConfigureServices`, add after the Storage Phase 3 registrations:
```csharp
// Phase 3: File Search
services.AddTransient<ISearchService, SearchService>();
services.AddTransient<SearchCsvExportService>();
services.AddTransient<SearchHtmlExportService>();
services.AddTransient<SearchViewModel>();
services.AddTransient<SearchView>();
// Phase 3: Duplicates
services.AddTransient<IDuplicatesService, DuplicatesService>();
services.AddTransient<DuplicatesHtmlExportService>();
services.AddTransient<DuplicatesViewModel>();
services.AddTransient<DuplicatesView>();
```
In `MainWindow.xaml`, add `x:Name` to the Search and Duplicates tab items:
```xml
<!-- Change from FeatureTabBase stubs to named TabItems -->
<TabItem x:Name="SearchTabItem"
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.search]}">
</TabItem>
<TabItem x:Name="DuplicatesTabItem"
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.duplicates]}">
</TabItem>
```
In `MainWindow.xaml.cs`, add after the StorageTabItem wiring line:
```csharp
SearchTabItem.Content = serviceProvider.GetRequiredService<SearchView>();
DuplicatesTabItem.Content = serviceProvider.GetRequiredService<DuplicatesView>();
```
**Visual Checkpoint** — after build succeeds, launch the application and verify:
1. The Storage tab shows the site URL input, scan options (Per-Library, Include Subsites, Folder Depth, Max Depth), Generate Metrics button, and an empty DataGrid
2. The File Search tab shows the filter panel (Extensions, Name/Regex, date range checkboxes, Created By, Modified By, Library, Max Results) and the Run Search button
3. The Duplicates tab shows the type selector (Files/Folders), criteria checkboxes, and Run Scan button
4. Language switching (EN ↔ FR) updates all Phase 3 tab labels without restart
**Verification:**
```bash
dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx
dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj -x 2>&1 | tail -10
```
Expected: 0 build errors; all tests pass (no regressions from Phase 1/2)
## Verification
```bash
dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx
dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj -x
```
Expected: 0 errors, all tests pass
## Checkpoint
**Type:** checkpoint:human-verify
**What was built:** All three Phase 3 tabs (Storage, File Search, Duplicates) are wired into the running application. All Phase 3 services are registered in DI. All Phase 3 test suites pass.
**How to verify:**
1. `dotnet run --project C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox/SharepointToolbox.csproj`
2. Confirm the Storage tab appears with site URL input and Generate Metrics button
3. Confirm the File Search tab appears with filter controls and Run Search button
4. Confirm the Duplicates tab appears with type selector and Run Scan button
5. Switch language to French (Settings tab) — confirm Phase 3 tab headers and labels update
6. Run the full test suite: `dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj -x`
**Resume signal:** Type "approved" when all six checks pass, or describe any issues found.
## Commit Message
feat(03-08): create SearchViewModel, DuplicatesViewModel, XAML views, DI wiring — Phase 3 complete
## Output
After completion, create `.planning/phases/03-storage/03-08-SUMMARY.md`

View File

@@ -0,0 +1,81 @@
---
phase: 03
plan: 08
subsystem: ui-viewmodels
tags: [wpf, viewmodel, search, duplicates, di, xaml]
dependency_graph:
requires: [03-05, 03-06, 03-07]
provides: [SearchViewModel, DuplicatesViewModel, SearchView, DuplicatesView, Phase3-DI]
affects: [App.xaml.cs, MainWindow.xaml, MainWindow.xaml.cs]
tech_stack:
added: []
patterns: [FeatureViewModelBase, AsyncRelayCommand, TenantProfile-site-override, DI-tab-wiring]
key_files:
created:
- SharepointToolbox/ViewModels/Tabs/SearchViewModel.cs
- SharepointToolbox/Views/Tabs/SearchView.xaml
- SharepointToolbox/Views/Tabs/SearchView.xaml.cs
- SharepointToolbox/ViewModels/Tabs/DuplicatesViewModel.cs
- SharepointToolbox/Views/Tabs/DuplicatesView.xaml
- SharepointToolbox/Views/Tabs/DuplicatesView.xaml.cs
modified:
- SharepointToolbox/App.xaml.cs
- SharepointToolbox/MainWindow.xaml
- SharepointToolbox/MainWindow.xaml.cs
decisions:
- SearchViewModel and DuplicatesViewModel use TenantProfile site URL override pattern — ctx.Url is read-only in CSOM (established pattern from StorageViewModel)
- DuplicateRow flat DTO wraps DuplicateItem with GroupName and GroupSize for DataGrid display
metrics:
duration: 4min
completed_date: "2026-04-02"
tasks: 3
files: 9
---
# Phase 3 Plan 08: SearchViewModel + DuplicatesViewModel + Views + DI Wiring Summary
**One-liner:** SearchViewModel and DuplicatesViewModel with full XAML views wired into MainWindow via DI, completing Phase 3 Storage feature tabs.
## Tasks Completed
| # | Name | Commit | Files |
|---|------|--------|-------|
| 1a | SearchViewModel + SearchView | 7e6d39a | SearchViewModel.cs, SearchView.xaml, SearchView.xaml.cs |
| 1b | DuplicatesViewModel + DuplicatesView | 0984a36 | DuplicatesViewModel.cs, DuplicatesView.xaml, DuplicatesView.xaml.cs |
| 2 | DI registration + MainWindow wiring | 1f2a49d | App.xaml.cs, MainWindow.xaml, MainWindow.xaml.cs |
## What Was Built
**SearchViewModel** (`SearchViewModel.cs`): Full filter state (extensions, regex, 4 date range checkboxes, createdBy, modifiedBy, library, maxResults), `RunOperationAsync` that calls `ISearchService.SearchFilesAsync`, `ExportCsvCommand` + `ExportHtmlCommand` with CanExport guard, `OnTenantSwitched` clears results.
**SearchView.xaml**: Left filter panel (260px ScrollViewer) with GroupBox for filters, Run Search + Cancel buttons, Export CSV/HTML group, status TextBlock. Right: full-width DataGrid with 8 columns (name, ext, created, author, modified, modifiedBy, size, path) using `BytesConverter` and `RightAlignStyle`.
**DuplicatesViewModel** (`DuplicatesViewModel.cs`): Mode (Files/Folders), 5 criteria checkboxes, IncludeSubsites, Library, `RunOperationAsync` that calls `IDuplicatesService.ScanDuplicatesAsync`, flattens `DuplicateGroup.Items` to flat `DuplicateRow` list for DataGrid, `ExportHtmlCommand`.
**DuplicatesView.xaml**: Left options panel (240px) with type RadioButtons, criteria checkboxes, library TextBox, IncludeSubsites checkbox, Run Scan + Cancel + Export HTML buttons. Right: DataGrid with group, copies, name, library, size, created, modified, path columns.
**DI + Wiring**: App.xaml.cs registers all Phase 3 Search and Duplicates services and views. MainWindow.xaml replaces FeatureTabBase stubs with named TabItems. MainWindow.xaml.cs wires content from DI.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Fixed ctx.Url read-only error in SearchViewModel**
- **Found during:** Task 1a verification build
- **Issue:** Plan code used `ctx.Url = SiteUrl.TrimEnd('/')``ClientRuntimeContext.Url` is read-only in CSOM (CS0200)
- **Fix:** Replaced with `new TenantProfile { TenantUrl = SiteUrl.TrimEnd('/'), ClientId = ..., Name = ... }` and passed to `GetOrCreateContextAsync` — identical to StorageViewModel pattern documented in STATE.md
- **Files modified:** SearchViewModel.cs
- **Commit:** 7e6d39a (fix applied in same commit)
**2. [Rule 1 - Bug] Pre-emptively fixed ctx.Url in DuplicatesViewModel**
- **Found during:** Task 1b (same issue pattern as Task 1a)
- **Issue:** Plan code also used `ctx.Url =` for DuplicatesViewModel
- **Fix:** Same TenantProfile override pattern applied before writing the file
- **Files modified:** DuplicatesViewModel.cs
- **Commit:** 0984a36
## Pre-existing Test Failure (Out of Scope)
`FeatureViewModelBaseTests.CancelCommand_DuringOperation_SetsStatusMessageToCancelled` fails because test asserts `.Contains("cancel")` (case-insensitive) but the app returns French string "Opération annulée". This failure predates this plan (confirmed via git stash test). Out of scope — logged to deferred items.
## Self-Check: PASSED

View File

@@ -0,0 +1,756 @@
# Phase 3: Storage and File Operations - Research
**Researched:** 2026-04-02
**Domain:** CSOM StorageMetrics, SharePoint KQL Search, WPF DataGrid, duplicate detection
**Confidence:** HIGH
---
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|-----------------|
| STOR-01 | User can view storage consumption per library on a site | CSOM `Folder.StorageMetrics` (one Load call per folder) + flat DataGrid with indent column |
| STOR-02 | User can view storage consumption per site with configurable folder depth | Recursive `Collect-FolderStorage` pattern translated to async CSOM; depth guard via split-count |
| STOR-03 | Storage metrics include total size, version size, item count, and last modified date | `StorageMetrics.TotalSize`, `TotalFileStreamSize`, `TotalFileCount`, `StorageMetrics.LastModified`; version size = TotalSize - TotalFileStreamSize |
| STOR-04 | User can export storage metrics to CSV | New `StorageCsvExportService` — same UTF-8 BOM pattern as Phase 2 |
| STOR-05 | User can export storage metrics to interactive HTML with collapsible tree view | New `StorageHtmlExportService` — port PS lines 1621-1780; toggle() JS + nested table rows |
| SRCH-01 | User can search files across sites using multiple criteria | `KeywordQuery` + `SearchExecutor` (CSOM search); KQL built from filter params; client-side Regex post-filter |
| SRCH-02 | User can configure maximum search results (up to 50,000) | SharePoint Search `StartRow` hard cap is 50,000 (boundary); 500 rows/batch × 100 pages = 50,000 max |
| SRCH-03 | User can export search results to CSV | New `SearchCsvExportService` |
| SRCH-04 | User can export search results to interactive HTML (sortable, filterable) | New `SearchHtmlExportService` — port PS lines 2112-2233; sortable columns via data attributes |
| DUPL-01 | User can scan for duplicate files by name, size, creation date, modification date | Search API (same as SRCH) + client-side GroupBy composite key; no content hashing needed |
| DUPL-02 | User can scan for duplicate folders by name, subfolder count, file count | `SharePointPaginationHelper.GetAllItemsAsync` with CAML `FSObjType=1`; read `FolderChildCount`, `ItemChildCount` from field values |
| DUPL-03 | User can export duplicate report to HTML with grouped display and visual indicators | New `DuplicatesHtmlExportService` — port PS lines 2235-2406; collapsible group cards, ok/diff badges |
</phase_requirements>
---
## Summary
Phase 3 introduces three feature areas (Storage Metrics, File Search, Duplicate Detection), each requiring a dedicated ViewModel, View, Service, and export services. All three areas can be implemented without adding new NuGet packages — `Microsoft.SharePoint.Client.Search.dll` is already in the output folder as a transitive dependency of PnP.Framework 1.18.0.
**Storage** uses CSOM `Folder.StorageMetrics` (loaded via `ctx.Load(folder, f => f.StorageMetrics)`). One CSOM round-trip per folder. Version size is derived as `TotalSize - TotalFileStreamSize`. The data model is a recursive tree (site → library → folder → subfolder), flattened to a `DataGrid` with an indent-level column for WPF display. The HTML export ports the PS `Export-StorageToHTML` function (PS lines 1621-1780) with its toggle(i) JS pattern.
**File Search** uses `Microsoft.SharePoint.Client.Search.Query.KeywordQuery` + `SearchExecutor`. KQL is assembled from UI filter fields (extension, date range, creator, editor, library path). Pagination is `StartRow += 500` per batch; the hard ceiling is `StartRow = 50,000` (SharePoint Search boundary), which means the 50,000 max-results requirement (SRCH-02) is exactly the platform limit. Client-side Regex is applied after retrieval. The HTML export ports PS lines 2112-2233.
**Duplicate Detection** uses the same Search API for file duplicates (with all documents query) and `SharePointPaginationHelper.GetAllItemsAsync` with FSObjType CAML filter for folder duplicates. Items are grouped client-side by a composite key (name + optional size/dates/counts). No content hashing is needed — the DUPL-01/02/03 requirements specify name+size+dates, which exactly matches the PS reference implementation.
**Primary recommendation:** Three ViewModels (StorageViewModel, SearchViewModel, DuplicatesViewModel), three service interfaces, six export services (storage CSV/HTML, search CSV/HTML, duplicates HTML — duplicates CSV is bonus), all extending existing Phase 2 patterns.
---
## User Constraints
No CONTEXT.md exists for Phase 3 (no /gsd:discuss-phase was run). All decisions below are from the locked technology stack in the prompt.
### Locked Decisions
- .NET 10 LTS + WPF + MVVM (CommunityToolkit.Mvvm 8.4.2)
- PnP.Framework 1.18.0 (CSOM-based SharePoint access)
- No new major packages preferred — only add if truly necessary
- Microsoft.Extensions.Hosting DI
- Serilog logging
- xUnit 2.9.3 tests
### Deferred / Out of Scope
- Content hashing for duplicate detection (v2)
- Storage charts/graphs (v2 requirement VIZZ-01/02/03)
- Cross-tenant file search
---
## Standard Stack
### Core (no new packages needed)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| PnP.Framework | 1.18.0 | CSOM access, `ClientContext` | Already in project |
| Microsoft.SharePoint.Client.Search.dll | (via PnP.Framework) | `KeywordQuery`, `SearchExecutor` | Transitive dep — confirmed present in `bin/Debug/net10.0-windows/` |
| CommunityToolkit.Mvvm | 8.4.2 | `[ObservableProperty]`, `AsyncRelayCommand` | Already in project |
| Microsoft.Extensions.Hosting | 10.x | DI container | Already in project |
| Serilog | 4.3.1 | Structured logging | Already in project |
| xUnit | 2.9.3 | Tests | Already in project |
| Moq | 4.20.72 | Mock interfaces in tests | Already in project |
**No new NuGet packages required.** `Microsoft.SharePoint.Client.Search.dll` ships as a transitive dependency of PnP.Framework — confirmed present at `SharepointToolbox/bin/Debug/net10.0-windows/Microsoft.SharePoint.Client.Search.dll`.
### New Models Needed
| Model | Location | Fields |
|-------|----------|--------|
| `StorageNode` | `Core/Models/StorageNode.cs` | `string Name`, `string Url`, `string SiteTitle`, `string Library`, `long TotalSizeBytes`, `long FileStreamSizeBytes`, `long TotalFileCount`, `DateTime? LastModified`, `int IndentLevel`, `List<StorageNode> Children` |
| `SearchResult` | `Core/Models/SearchResult.cs` | `string Title`, `string Path`, `string FileExtension`, `DateTime? Created`, `DateTime? LastModified`, `string Author`, `string ModifiedBy`, `long SizeBytes` |
| `DuplicateGroup` | `Core/Models/DuplicateGroup.cs` | `string GroupKey`, `string Name`, `List<DuplicateItem> Items` |
| `DuplicateItem` | `Core/Models/DuplicateItem.cs` | `string Name`, `string Path`, `string Library`, `long? SizeBytes`, `DateTime? Created`, `DateTime? Modified`, `int? FolderCount`, `int? FileCount` |
| `StorageScanOptions` | `Core/Models/StorageScanOptions.cs` | `bool PerLibrary`, `bool IncludeSubsites`, `int FolderDepth` |
| `SearchOptions` | `Core/Models/SearchOptions.cs` | `string[] Extensions`, `string? Regex`, `DateTime? CreatedAfter`, `DateTime? CreatedBefore`, `DateTime? ModifiedAfter`, `DateTime? ModifiedBefore`, `string? CreatedBy`, `string? ModifiedBy`, `string? Library`, `int MaxResults` |
| `DuplicateScanOptions` | `Core/Models/DuplicateScanOptions.cs` | `string Mode` ("Files"/"Folders"), `bool MatchSize`, `bool MatchCreated`, `bool MatchModified`, `bool MatchSubfolderCount`, `bool MatchFileCount`, `bool IncludeSubsites`, `string? Library` |
---
## Architecture Patterns
### Recommended Project Structure (additions only)
```
SharepointToolbox/
├── Core/Models/
│ ├── StorageNode.cs # new
│ ├── SearchResult.cs # new
│ ├── DuplicateGroup.cs # new
│ ├── DuplicateItem.cs # new
│ ├── StorageScanOptions.cs # new
│ ├── SearchOptions.cs # new
│ └── DuplicateScanOptions.cs # new
├── Services/
│ ├── IStorageService.cs # new
│ ├── StorageService.cs # new
│ ├── ISearchService.cs # new
│ ├── SearchService.cs # new
│ ├── IDuplicatesService.cs # new
│ ├── DuplicatesService.cs # new
│ └── Export/
│ ├── StorageCsvExportService.cs # new
│ ├── StorageHtmlExportService.cs # new
│ ├── SearchCsvExportService.cs # new
│ ├── SearchHtmlExportService.cs # new
│ └── DuplicatesHtmlExportService.cs # new
├── ViewModels/Tabs/
│ ├── StorageViewModel.cs # new
│ ├── SearchViewModel.cs # new
│ └── DuplicatesViewModel.cs # new
└── Views/Tabs/
├── StorageView.xaml # new
├── StorageView.xaml.cs # new
├── SearchView.xaml # new
├── SearchView.xaml.cs # new
├── DuplicatesView.xaml # new
└── DuplicatesView.xaml.cs # new
```
### Pattern 1: CSOM StorageMetrics Load
**What:** Load `Folder.StorageMetrics` with a single round-trip per folder. StorageMetrics is a child object — you must include it in the Load expression or it will not be fetched.
**When to use:** Whenever reading storage data for a folder or library root.
**Example:**
```csharp
// Source: https://learn.microsoft.com/en-us/dotnet/api/microsoft.sharepoint.client.storagemetrics
// + https://longnlp.github.io/load-storage-metric-from-SPO
// Get folder by server-relative URL (library root or subfolder)
Folder folder = ctx.Web.GetFolderByServerRelativeUrl(serverRelativeUrl);
ctx.Load(folder,
f => f.StorageMetrics, // pulls TotalSize, TotalFileStreamSize, TotalFileCount, LastModified
f => f.TimeLastModified, // alternative timestamp if StorageMetrics.LastModified is null
f => f.ServerRelativeUrl,
f => f.Name);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
long totalBytes = folder.StorageMetrics.TotalSize;
long streamBytes = folder.StorageMetrics.TotalFileStreamSize; // current-version files only
long versionBytes = Math.Max(0L, totalBytes - streamBytes); // version overhead
long fileCount = folder.StorageMetrics.TotalFileCount;
DateTime? lastMod = folder.StorageMetrics.IsPropertyAvailable("LastModified")
? folder.StorageMetrics.LastModified
: folder.TimeLastModified;
```
**Unit:** `TotalSize` and `TotalFileStreamSize` are in **bytes** (Int64). `TotalFileStreamSize` is the aggregate stream size for current-version file content only — it excludes version history, metadata, and attachments (confirmed by [MS-CSOMSPT]). Version storage = `TotalSize - TotalFileStreamSize`.
### Pattern 2: KQL Search with Pagination
**What:** Use `KeywordQuery` + `SearchExecutor` (in `Microsoft.SharePoint.Client.Search.Query`) to execute a KQL query, paginating 500 rows at a time via `StartRow`.
**When to use:** SRCH-01/02/03/04 (file search) and DUPL-01 (file duplicate detection).
**Example:**
```csharp
// Source: https://learn.microsoft.com/en-us/dotnet/api/microsoft.sharepoint.client.search.query.searchexecutor
// + https://usefulscripts.wordpress.com/2015/09/11/how-to-fetch-all-results-from-sharepoint-search-using-dot-net-managed-csom/
using Microsoft.SharePoint.Client.Search.Query;
// namespace: Microsoft.SharePoint.Client.Search.Query
// assembly: Microsoft.SharePoint.Client.Search.dll (via PnP.Framework transitive dep)
var allResults = new List<IDictionary<string, object>>();
int startRow = 0;
const int batchSize = 500;
do
{
ct.ThrowIfCancellationRequested();
var kq = new KeywordQuery(ctx)
{
QueryText = kql, // e.g. "ContentType:Document AND FileExtension:pdf"
StartRow = startRow,
RowLimit = batchSize,
TrimDuplicates = false
};
// Explicit managed properties to retrieve
kq.SelectProperties.AddRange(new[]
{
"Title", "Path", "Author", "LastModifiedTime",
"FileExtension", "Created", "ModifiedBy", "Size"
});
var executor = new SearchExecutor(ctx);
ClientResult<ResultTableCollection> clientResult = executor.ExecuteQuery(kq);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
// Note: ctx.ExecuteQuery() is called inside ExecuteQueryRetryAsync — do NOT call again
var table = clientResult.Value
.FirstOrDefault(t => t.TableType == KnownTableTypes.RelevantResults);
if (table == null) break;
int retrieved = table.RowCount;
foreach (System.Collections.Hashtable row in table.ResultRows)
{
allResults.Add(row.Cast<System.Collections.DictionaryEntry>()
.ToDictionary(e => e.Key.ToString()!, e => e.Value ?? string.Empty));
}
progress.Report(new OperationProgress(allResults.Count, maxResults, $"Retrieved {allResults.Count} results…"));
startRow += batchSize;
}
while (startRow < maxResults && startRow <= 50_000 // platform hard cap
&& allResults.Count < maxResults);
```
**Critical detail:** `ExecuteQueryRetryHelper.ExecuteQueryRetryAsync` wraps `ctx.ExecuteQuery()`. Call it AFTER `executor.ExecuteQuery(kq)` — do NOT call `ctx.ExecuteQuery()` directly afterward.
**StartRow limit:** SharePoint Search imposes a hard boundary of 50,000 for `StartRow`. With batch size 500, max pages = 100, max results = 50,000. This exactly satisfies SRCH-02.
**KQL field mappings (from PS reference lines 4747-4763):**
- Extension: `FileExtension:pdf OR FileExtension:docx`
- Created after/before: `Created>=2024-01-01` / `Created<=2024-12-31`
- Modified after/before: `Write>=2024-01-01` / `Write<=2024-12-31`
- Created by: `Author:"First Last"`
- Modified by: `ModifiedBy:"First Last"`
- Library path: `Path:"https://tenant.sharepoint.com/sites/x/Shared Documents*"`
- Documents only: `ContentType:Document`
### Pattern 3: Folder Enumeration for Duplicate Folders
**What:** Use `SharePointPaginationHelper.GetAllItemsAsync` with a CAML filter on `FSObjType = 1` (folders). Read `FolderChildCount` and `ItemChildCount` from `FieldValues`.
**When to use:** DUPL-02 (folder duplicate scan).
**Example:**
```csharp
// Source: PS reference lines 5010-5036; Phase 2 SharePointPaginationHelper pattern
var camlQuery = new CamlQuery
{
ViewXml = @"<View Scope='RecursiveAll'>
<Query>
<Where>
<Eq>
<FieldRef Name='FSObjType' />
<Value Type='Integer'>1</Value>
</Eq>
</Where>
</Query>
<RowLimit>2000</RowLimit>
</View>"
};
await foreach (var item in SharePointPaginationHelper.GetAllItemsAsync(ctx, list, camlQuery, ct))
{
var fv = item.FieldValues;
var name = fv["FileLeafRef"]?.ToString() ?? string.Empty;
var fileRef = fv["FileRef"]?.ToString() ?? string.Empty;
var subCount = Convert.ToInt32(fv["FolderChildCount"] ?? 0);
var childCount = Convert.ToInt32(fv["ItemChildCount"] ?? 0);
var fileCount = Math.Max(0, childCount - subCount);
var created = fv["Created"] is DateTime cr ? cr : (DateTime?)null;
var modified = fv["Modified"] is DateTime md ? md : (DateTime?)null;
// ...build DuplicateItem
}
```
### Pattern 4: Duplicate Composite Key (name+size+date grouping)
**What:** Build a string composite key from the fields the user selected, then `GroupBy(key).Where(g => g.Count() >= 2)`.
**When to use:** DUPL-01 (files) and DUPL-02 (folders).
**Example:**
```csharp
// Source: PS reference lines 4942-4949 (MakeKey function)
private static string MakeKey(DuplicateItem item, DuplicateScanOptions opts)
{
var parts = new List<string> { item.Name.ToLowerInvariant() };
if (opts.MatchSize && item.SizeBytes.HasValue) parts.Add(item.SizeBytes.Value.ToString());
if (opts.MatchCreated && item.Created.HasValue) parts.Add(item.Created.Value.Date.ToString("yyyy-MM-dd"));
if (opts.MatchModified && item.Modified.HasValue) parts.Add(item.Modified.Value.Date.ToString("yyyy-MM-dd"));
if (opts.MatchSubfolderCount && item.FolderCount.HasValue) parts.Add(item.FolderCount.Value.ToString());
if (opts.MatchFileCount && item.FileCount.HasValue) parts.Add(item.FileCount.Value.ToString());
return string.Join("|", parts);
}
var groups = allItems
.GroupBy(i => MakeKey(i, opts))
.Where(g => g.Count() >= 2)
.Select(g => new DuplicateGroup
{
GroupKey = g.Key,
Name = g.First().Name,
Items = g.ToList()
})
.OrderByDescending(g => g.Items.Count)
.ToList();
```
### Pattern 5: Storage Recursive Tree → Flat Row List for DataGrid
**What:** Flatten the recursive tree (site → library → folder → subfolder) into a flat `List<StorageNode>` where each node carries an `IndentLevel`. The WPF `DataGrid` renders a `Margin` on the name cell based on `IndentLevel`.
**When to use:** STOR-01/02 WPF display.
**Rationale for DataGrid over TreeView:** WPF `TreeView` requires hierarchical `HierarchicalDataTemplate` and loses virtualization with deep nesting. A flat `DataGrid` with `VirtualizingPanel.IsVirtualizing="True"` stays performant for thousands of rows and is trivially sortable.
**Example:**
```csharp
// Flatten tree to observable list for DataGrid binding
private static void FlattenTree(StorageNode node, int level, List<StorageNode> result)
{
node.IndentLevel = level;
result.Add(node);
foreach (var child in node.Children)
FlattenTree(child, level + 1, result);
}
```
```xml
<!-- WPF DataGrid cell template for name column with indent -->
<DataGridTemplateColumn Header="Library / Folder" Width="*">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding Name}"
Margin="{Binding IndentLevel, Converter={StaticResource IndentConverter}}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
```
Use `IValueConverter` mapping `IndentLevel``new Thickness(IndentLevel * 16, 0, 0, 0)`.
### Pattern 6: Storage HTML Collapsible Tree
**What:** The HTML export uses inline nested tables with `display:none` rows toggled by `toggle(i)` JS. Each library/folder that has children gets a unique numeric index.
**When to use:** STOR-05 export.
**Key design (from PS lines 1621-1780):**
- A global `_togIdx` counter assigns unique IDs to collapsible rows: `<tr id='sf-{i}' style='display:none'>`.
- A `<button onclick='toggle({i})'>` triggers `row.style.display = visible ? 'none' : 'table-row'`.
- Library rows embed a nested `<table class='sf-tbl'>` inside the collapsible row (colspan spanning all columns).
- This is a pure inline pattern — no external JS or CSS dependencies.
- In C# the counter is a field on `StorageHtmlExportService` reset at the start of each `BuildHtml()` call.
### Anti-Patterns to Avoid
- **Loading StorageMetrics without including it in ctx.Load:** `folder.StorageMetrics.TotalSize` throws `PropertyOrFieldNotInitializedException` if `StorageMetrics` is not included in the Load expression. Always use `ctx.Load(folder, f => f.StorageMetrics, ...)`.
- **Calling ctx.ExecuteQuery() after executor.ExecuteQuery(kq):** The search executor pattern requires calling `ctx.ExecuteQuery()` ONCE (inside `ExecuteQueryRetryAsync`). Calling it twice is a no-op at best, throws at worst.
- **StartRow > 50,000:** SharePoint Search hard boundary — will return zero results or error. Cap loop exit at `startRow <= 50_000`.
- **Modifying ObservableCollection from Task.Run:** Same rule as Phase 2 — accumulate in `List<T>` on background thread, then `Dispatcher.InvokeAsync(() => StorageResults = new ObservableCollection<T>(list))`.
- **Recursive CSOM calls without depth guard:** Without a depth guard, `Collect-FolderStorage` on a deep site can make thousands of CSOM round-trips. Always pass `MaxDepth` and check `currentDepth >= maxDepth` before recursing.
- **Building a TreeView for storage display:** WPF TreeView loses UI virtualization with more than ~1000 visible items. Use DataGrid with IndentLevel.
- **Version size from index:** The Search API's `Size` property is the current-version file size, not total including versions. Only `StorageMetrics.TotalFileStreamSize` vs `TotalSize` gives accurate version overhead.
---
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| CSOM throttle retry | Custom retry loop | `ExecuteQueryRetryHelper.ExecuteQueryRetryAsync` (Phase 1) | Already handles 429/503 with exponential backoff |
| List pagination | Raw `ExecuteQuery` loop | `SharePointPaginationHelper.GetAllItemsAsync` (Phase 1) | Handles 5000-item threshold, CAML position continuation |
| Search pagination | Manual `do/while` per search | Same `KeywordQuery`+`SearchExecutor` pattern (internal to SearchService) | Wrap in a helper method inside `SearchService` to avoid duplication across SRCH and DUPL features |
| HTML header/footer boilerplate | New template each export service | Copy from existing `HtmlExportService` pattern (Phase 2) | Consistent `<!DOCTYPE>`, viewport meta, `Segoe UI` font stack |
| CSV field escaping | Custom escaping | RFC 4180 `Csv()` helper pattern from Phase 2 `CsvExportService` | Already handles quotes, empty values, UTF-8 BOM |
| OperationProgress reporting | New progress model | `OperationProgress.Indeterminate(msg)` + `new OperationProgress(current, total, msg)` (Phase 1) | Already wired to UI via `FeatureViewModelBase` |
| Tenant context management | Directly create `ClientContext` | `ISessionManager.GetOrCreateContextAsync` (Phase 1) | Handles MSAL cache, per-tenant context pooling |
---
## Common Pitfalls
### Pitfall 1: StorageMetrics PropertyOrFieldNotInitializedException
**What goes wrong:** `folder.StorageMetrics.TotalSize` throws `PropertyOrFieldNotInitializedException` at runtime.
**Why it happens:** CSOM lazy-loading — if `StorageMetrics` is not in the Load expression, the proxy object exists but has no data.
**How to avoid:** Always include `f => f.StorageMetrics` in the `ctx.Load(folder, ...)` lambda.
**Warning signs:** Exception message contains "The property or field 'StorageMetrics' has not been initialized".
### Pitfall 2: Search ResultRows Type Is IDictionary-like But Not Strongly Typed
**What goes wrong:** Accessing `row["Size"]` returns object — Size comes back as a string `"12345"` not a long.
**Why it happens:** `ResultTable.ResultRows` is `IEnumerable<IDictionary<string, object>>`. All values are strings from the search index.
**How to avoid:** Always parse with `long.TryParse(row["Size"]?.ToString() ?? "0", out var sizeBytes)`. Strip non-numeric characters as PS does: `Regex.Replace(sizeStr, "[^0-9]", "")`.
**Warning signs:** `InvalidCastException` when binding Size to a numeric column.
### Pitfall 3: Search API Returns Duplicates for Versioned Files
**What goes wrong:** Files with many versions appear multiple times in results via `/_vti_history/` paths.
**Why it happens:** SharePoint indexes each version as a separate item in some cases.
**How to avoid:** Filter items where `Path.Contains("/_vti_history/", StringComparison.OrdinalIgnoreCase)` — port of PS line 4973.
**Warning signs:** Duplicate file paths in results with `_vti_history` segment.
### Pitfall 4: StorageMetrics.LastModified May Be DateTime.MinValue
**What goes wrong:** `LastModified` shows as 01/01/0001 for empty folders.
**Why it happens:** SharePoint returns a default DateTime for folders with no modifications.
**How to avoid:** Check `lastModified > DateTime.MinValue` before formatting. Fall back to `folder.TimeLastModified` if `StorageMetrics.LastModified` is unset.
**Warning signs:** "01/01/0001" in the LastModified column.
### Pitfall 5: KQL Query Text Exceeds 4096 Characters
**What goes wrong:** Search query silently fails or returns error for very long KQL strings.
**Why it happens:** SharePoint Search has a 4096-character KQL text boundary.
**How to avoid:** For extension filters with many extensions, use `(FileExtension:a OR FileExtension:b OR ...)` and validate total length before calling. Warn user if limit approached.
**Warning signs:** Zero results returned when many extensions entered; no CSOM exception.
### Pitfall 6: CAML FSObjType Field Name
**What goes wrong:** CAML query for folders returns no results.
**Why it happens:** The internal CAML field name is `FSObjType`, not `FileSystemObjectType`. Using the wrong name returns no matches silently.
**How to avoid:** Use `<FieldRef Name='FSObjType' />` (integer) with `<Value Type='Integer'>1</Value>`. Confirmed by PS reference line 5011 which uses CSOM `FileSystemObjectType.Folder` comparison.
**Warning signs:** Zero items returned from folder CAML query on a library known to have folders.
### Pitfall 7: StorageService Needs Web.ServerRelativeUrl to Compute Site-Relative Path
**What goes wrong:** `Get-PnPFolderStorageMetric -FolderSiteRelativeUrl` requires a path relative to the web root (e.g., `Shared Documents`), not the server root (e.g., `/sites/MySite/Shared Documents`).
**Why it happens:** CSOM `Folder.StorageMetrics` uses server-relative URLs, so you need to strip the web's ServerRelativeUrl prefix.
**How to avoid:** Load `ctx.Web.ServerRelativeUrl` first, then compute: `siteRelUrl = rootFolder.ServerRelativeUrl.Substring(webSrl.Length).TrimStart('/')`. Use `ctx.Web.GetFolderByServerRelativeUrl(siteAbsoluteUrl)` which accepts full server-relative paths.
**Warning signs:** 404/FileNotFoundException from CSOM when calling StorageMetrics.
---
## Code Examples
### Loading StorageMetrics (STOR-01/02/03)
```csharp
// Source: MS Learn — StorageMetrics Class; [MS-CSOMSPT] TotalFileStreamSize definition
ctx.Load(ctx.Web, w => w.ServerRelativeUrl, w => w.Url, w => w.Title);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
string webSrl = ctx.Web.ServerRelativeUrl.TrimEnd('/');
// Per-library: iterate document libraries
ctx.Load(ctx.Web.Lists, lists => lists.Include(
l => l.Title, l => l.BaseType, l => l.Hidden, l => l.RootFolder.ServerRelativeUrl));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
foreach (var list in ctx.Web.Lists)
{
if (list.Hidden || list.BaseType != BaseType.DocumentLibrary) continue;
string siteRelUrl = list.RootFolder.ServerRelativeUrl.Substring(webSrl.Length).TrimStart('/');
Folder rootFolder = ctx.Web.GetFolderByServerRelativeUrl(list.RootFolder.ServerRelativeUrl);
ctx.Load(rootFolder,
f => f.StorageMetrics,
f => f.TimeLastModified,
f => f.ServerRelativeUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var node = new StorageNode
{
Name = list.Title,
Url = $"{ctx.Web.Url.TrimEnd('/')}/{siteRelUrl}",
SiteTitle = ctx.Web.Title,
Library = list.Title,
TotalSizeBytes = rootFolder.StorageMetrics.TotalSize,
FileStreamSizeBytes = rootFolder.StorageMetrics.TotalFileStreamSize,
TotalFileCount = rootFolder.StorageMetrics.TotalFileCount,
LastModified = rootFolder.StorageMetrics.LastModified > DateTime.MinValue
? rootFolder.StorageMetrics.LastModified
: rootFolder.TimeLastModified,
IndentLevel = 0,
Children = new List<StorageNode>()
};
// Recursive subfolder collection up to maxDepth
if (maxDepth > 0)
await CollectSubfoldersAsync(ctx, list.RootFolder.ServerRelativeUrl, node, 1, maxDepth, progress, ct);
}
```
### KQL Build from SearchOptions
```csharp
// Source: PS reference lines 4747-4763
private static string BuildKql(SearchOptions opts)
{
var parts = new List<string> { "ContentType:Document" };
if (opts.Extensions.Length > 0)
{
var extParts = opts.Extensions.Select(e => $"FileExtension:{e.TrimStart('.').ToLowerInvariant()}");
parts.Add($"({string.Join(" OR ", extParts)})");
}
if (opts.CreatedAfter.HasValue)
parts.Add($"Created>={opts.CreatedAfter.Value:yyyy-MM-dd}");
if (opts.CreatedBefore.HasValue)
parts.Add($"Created<={opts.CreatedBefore.Value:yyyy-MM-dd}");
if (opts.ModifiedAfter.HasValue)
parts.Add($"Write>={opts.ModifiedAfter.Value:yyyy-MM-dd}");
if (opts.ModifiedBefore.HasValue)
parts.Add($"Write<={opts.ModifiedBefore.Value:yyyy-MM-dd}");
if (!string.IsNullOrEmpty(opts.CreatedBy))
parts.Add($"Author:\"{opts.CreatedBy}\"");
if (!string.IsNullOrEmpty(opts.ModifiedBy))
parts.Add($"ModifiedBy:\"{opts.ModifiedBy}\"");
if (!string.IsNullOrEmpty(opts.Library))
parts.Add($"Path:\"{opts.SiteUrl.TrimEnd('/')}/{opts.Library.TrimStart('/')}*\"");
return string.Join(" AND ", parts);
}
```
### Parsing Search ResultRows
```csharp
// Source: PS reference lines 4971-4987
private static SearchResult ParseRow(IDictionary<string, object> row)
{
static string Str(IDictionary<string, object> r, string key) =>
r.TryGetValue(key, out var v) ? v?.ToString() ?? string.Empty : string.Empty;
static DateTime? Date(IDictionary<string, object> r, string key)
{
var s = Str(r, key);
return DateTime.TryParse(s, out var dt) ? dt : null;
}
static long ParseSize(IDictionary<string, object> r, string key)
{
var raw = Str(r, key);
var digits = System.Text.RegularExpressions.Regex.Replace(raw, "[^0-9]", "");
return long.TryParse(digits, out var v) ? v : 0L;
}
return new SearchResult
{
Title = Str(row, "Title"),
Path = Str(row, "Path"),
FileExtension = Str(row, "FileExtension"),
Created = Date(row, "Created"),
LastModified = Date(row, "LastModifiedTime"),
Author = Str(row, "Author"),
ModifiedBy = Str(row, "ModifiedBy"),
SizeBytes = ParseSize(row, "Size")
};
}
```
---
## Localization Keys Needed
The following keys are needed for Phase 3 Views. Keys from the PS reference (lines 2747-2813) are remapped to the C# `Strings.resx` naming convention. Existing keys already in `Strings.resx` are marked with (existing).
### Storage Tab
| Key | EN Value | Notes |
|-----|----------|-------|
| `tab.storage` | `Storage` | (existing — already in Strings.resx line 77) |
| `chk.per.lib` | `Per-Library Breakdown` | new |
| `chk.subsites` | `Include Subsites` | new |
| `lbl.folder.depth` | `Folder depth:` | (existing — shared with permissions) |
| `chk.max.depth` | `Maximum (all levels)` | (existing — shared with permissions) |
| `stor.note` | `Note: deeper folder scans on large sites may take several minutes.` | new |
| `btn.gen.storage` | `Generate Metrics` | new |
| `btn.open.storage` | `Open Report` | new |
| `stor.col.library` | `Library` | new |
| `stor.col.site` | `Site` | new |
| `stor.col.files` | `Files` | new |
| `stor.col.size` | `Size` | new |
| `stor.col.versions` | `Versions` | new |
| `stor.col.lastmod` | `Last Modified` | new |
| `stor.col.share` | `Share of Total` | new |
### File Search Tab
| Key | EN Value | Notes |
|-----|----------|-------|
| `tab.search` | `File Search` | (existing — already in Strings.resx line 79) |
| `grp.search.filters` | `Search Filters` | new |
| `lbl.extensions` | `Extension(s):` | new |
| `ph.extensions` | `docx pdf xlsx` | new (placeholder) |
| `lbl.regex` | `Name / Regex:` | new |
| `ph.regex` | `Ex: report.* or \.bak$` | new (placeholder) |
| `chk.created.after` | `Created after:` | new |
| `chk.created.before` | `Created before:` | new |
| `chk.modified.after` | `Modified after:` | new |
| `chk.modified.before` | `Modified before:` | new |
| `lbl.created.by` | `Created by:` | new |
| `ph.created.by` | `First Last or email` | new (placeholder) |
| `lbl.modified.by` | `Modified by:` | new |
| `ph.modified.by` | `First Last or email` | new (placeholder) |
| `lbl.library` | `Library:` | new |
| `ph.library` | `Optional relative path e.g. Shared Documents` | new (placeholder) |
| `lbl.max.results` | `Max results:` | new |
| `btn.run.search` | `Run Search` | new |
| `btn.open.search` | `Open Results` | new |
| `srch.col.name` | `File Name` | new |
| `srch.col.ext` | `Extension` | new |
| `srch.col.created` | `Created` | new |
| `srch.col.modified` | `Modified` | new |
| `srch.col.author` | `Created By` | new |
| `srch.col.modby` | `Modified By` | new |
| `srch.col.size` | `Size` | new |
### Duplicates Tab
| Key | EN Value | Notes |
|-----|----------|-------|
| `tab.duplicates` | `Duplicates` | (existing — already in Strings.resx line 83) |
| `grp.dup.type` | `Duplicate Type` | new |
| `rad.dup.files` | `Duplicate files` | new |
| `rad.dup.folders` | `Duplicate folders` | new |
| `grp.dup.criteria` | `Comparison Criteria` | new |
| `lbl.dup.note` | `Name is always the primary criterion. Check additional criteria:` | new |
| `chk.dup.size` | `Same size` | new |
| `chk.dup.created` | `Same creation date` | new |
| `chk.dup.modified` | `Same modification date` | new |
| `chk.dup.subfolders` | `Same subfolder count` | new |
| `chk.dup.filecount` | `Same file count` | new |
| `chk.include.subsites` | `Include subsites` | new |
| `ph.dup.lib` | `All (leave empty)` | new (placeholder) |
| `btn.run.scan` | `Run Scan` | new |
| `btn.open.results` | `Open Results` | new |
---
## Duplicate Detection Scale — Known Concern Resolution
The STATE.md concern ("Duplicate detection at scale (100k+ files) — Graph API hash enumeration limits") is resolved: the PS reference does NOT use file hashes. It uses name+size+date grouping, which is exactly what DUPL-01/02/03 specify. The requirements do not mention hash-based deduplication.
**Scale analysis:**
- File duplicates use the Search API. SharePoint Search caps at 50,000 results (StartRow=50,000 max). A site with 100k+ files will be capped at 50,000 returned results. This is the same cap as SRCH-02, and is a known/accepted limitation.
- Folder duplicates use CAML pagination. `SharePointPaginationHelper.GetAllItemsAsync` handles arbitrary folder counts with RowLimit=2000 pagination — no effective upper bound.
- Client-side GroupBy on 50,000 items is instantaneous (Dictionary-based O(n) operation).
- **No Graph API or SHA256 content hashing is needed.** The concern was about a potential v2 enhancement not required by DUPL-01/02/03.
---
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| `Get-PnPFolderStorageMetric` (PS cmdlet) | CSOM `Folder.StorageMetrics` | Phase 3 migration | One CSOM round-trip per folder; no PnP PS module required |
| `Submit-PnPSearchQuery` (PS cmdlet) | CSOM `KeywordQuery` + `SearchExecutor` | Phase 3 migration | Same pagination model; TrimDuplicates=false explicit |
| `Get-PnPListItem` for folders (PS) | `SharePointPaginationHelper.GetAllItemsAsync` with CAML | Phase 3 migration | Reuses Phase 1 helper; handles 5000-item threshold |
| Storage TreeView control | Flat DataGrid with IndentLevel + IValueConverter | Phase 3 design decision | Better UI virtualization for large sites |
---
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | xUnit 2.9.3 |
| Config file | none (SDK auto-discovery) |
| Quick run command | `dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "Category!=Integration" -x` |
| Full suite command | `dotnet test SharepointToolbox.slnx` |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| STOR-01/02 | `StorageService.CollectStorageAsync` returns `StorageNode` list | unit (mock ISessionManager) | `dotnet test --filter "StorageServiceTests"` | ❌ Wave 0 |
| STOR-03 | VersionSizeBytes = TotalSizeBytes - FileStreamSizeBytes | unit | `dotnet test --filter "StorageNodeTests"` | ❌ Wave 0 |
| STOR-04 | `StorageCsvExportService.BuildCsv` produces correct header and rows | unit | `dotnet test --filter "StorageCsvExportServiceTests"` | ❌ Wave 0 |
| STOR-05 | `StorageHtmlExportService.BuildHtml` contains toggle JS and nested tables | unit | `dotnet test --filter "StorageHtmlExportServiceTests"` | ❌ Wave 0 |
| SRCH-01 | `SearchService` builds correct KQL from `SearchOptions` | unit | `dotnet test --filter "SearchServiceTests"` | ❌ Wave 0 |
| SRCH-02 | Search loop exits when `startRow > 50_000` | unit | `dotnet test --filter "SearchServiceTests"` | ❌ Wave 0 |
| SRCH-03 | `SearchCsvExportService.BuildCsv` produces correct header | unit | `dotnet test --filter "SearchCsvExportServiceTests"` | ❌ Wave 0 |
| SRCH-04 | `SearchHtmlExportService.BuildHtml` contains sort JS and filter input | unit | `dotnet test --filter "SearchHtmlExportServiceTests"` | ❌ Wave 0 |
| DUPL-01 | `MakeKey` function groups identical name+size+date items | unit | `dotnet test --filter "DuplicatesServiceTests"` | ❌ Wave 0 |
| DUPL-02 | CAML query targets `FSObjType=1`; `FileCount = ItemChildCount - FolderChildCount` | unit (logic only) | `dotnet test --filter "DuplicatesServiceTests"` | ❌ Wave 0 |
| DUPL-03 | `DuplicatesHtmlExportService.BuildHtml` contains group cards with ok/diff badges | unit | `dotnet test --filter "DuplicatesHtmlExportServiceTests"` | ❌ Wave 0 |
**Note:** `StorageService`, `SearchService`, and `DuplicatesService` depend on live CSOM — service-level tests use Skip like `PermissionsServiceTests`. ViewModel tests use Moq for `IStorageService`, `ISearchService`, `IDuplicatesService` following `PermissionsViewModelTests` pattern. Export service tests are fully unit-testable (no CSOM).
### Sampling Rate
- **Per task commit:** `dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj -x`
- **Per wave merge:** `dotnet test SharepointToolbox.slnx`
- **Phase gate:** Full suite green before `/gsd:verify-work`
### Wave 0 Gaps
- [ ] `SharepointToolbox.Tests/Services/StorageServiceTests.cs` — covers STOR-01/02 (stub + Skip like PermissionsServiceTests)
- [ ] `SharepointToolbox.Tests/Services/Export/StorageCsvExportServiceTests.cs` — covers STOR-04
- [ ] `SharepointToolbox.Tests/Services/Export/StorageHtmlExportServiceTests.cs` — covers STOR-05
- [ ] `SharepointToolbox.Tests/Services/SearchServiceTests.cs` — covers SRCH-01/02 (KQL build + pagination cap logic)
- [ ] `SharepointToolbox.Tests/Services/Export/SearchCsvExportServiceTests.cs` — covers SRCH-03
- [ ] `SharepointToolbox.Tests/Services/Export/SearchHtmlExportServiceTests.cs` — covers SRCH-04
- [ ] `SharepointToolbox.Tests/Services/DuplicatesServiceTests.cs` — covers DUPL-01/02 composite key logic
- [ ] `SharepointToolbox.Tests/Services/Export/DuplicatesHtmlExportServiceTests.cs` — covers DUPL-03
- [ ] `SharepointToolbox.Tests/ViewModels/StorageViewModelTests.cs` — covers STOR-01 ViewModel (Moq IStorageService)
- [ ] `SharepointToolbox.Tests/ViewModels/SearchViewModelTests.cs` — covers SRCH-01/02 ViewModel
- [ ] `SharepointToolbox.Tests/ViewModels/DuplicatesViewModelTests.cs` — covers DUPL-01/02 ViewModel
---
## Open Questions
1. **StorageMetrics.LastModified vs TimeLastModified**
- What we know: `StorageMetrics.LastModified` exists per the API docs. `Folder.TimeLastModified` is a separate CSOM property.
- What's unclear: Whether `StorageMetrics.LastModified` can return `DateTime.MinValue` for recently created empty folders in all SharePoint Online tenants.
- Recommendation: Load both (`f => f.StorageMetrics, f => f.TimeLastModified`) and prefer `StorageMetrics.LastModified` when it is `> DateTime.MinValue`, falling back to `TimeLastModified`.
2. **Search index freshness for duplicate detection**
- What we know: SharePoint Search is eventually consistent — newly created files may not appear for up to 15 minutes.
- What's unclear: Whether users expect real-time accuracy or accept eventual consistency.
- Recommendation: Document in UI that search-based results (files) reflect the search index, not the current state. Add a note in the log output.
3. **Multiple-site file search scope**
- What we know: The PS reference scopes search to `$siteUrl` context only (one site per search). SRCH-01 says "across sites" in the goal description but the requirements only specify search criteria, not multi-site.
- What's unclear: Whether SRCH-01 requires multi-site search in one operation or per-site.
- Recommendation: Implement per-site search (matching PS reference). Multi-site search would require separate `ClientContext` per site plus result merging — treat as a future enhancement.
---
## Sources
### Primary (HIGH confidence)
- [StorageMetrics Class — MS Learn CSOM reference](https://learn.microsoft.com/en-us/dotnet/api/microsoft.sharepoint.client.storagemetrics?view=sharepoint-csom) — properties TotalSize, TotalFileStreamSize, TotalFileCount, LastModified confirmed
- [StorageMetrics.TotalSize — MS Learn](https://learn.microsoft.com/en-us/dotnet/api/microsoft.sharepoint.client.storagemetrics.totalsize?view=sharepoint-csom) — confirmed as Int64, ReadOnly
- [[MS-CSOMSPT] TotalFileStreamSize](https://learn.microsoft.com/en-us/openspecs/sharepoint_protocols/ms-csomspt/635464fc-8505-43fa-97d7-02229acdb3c5) — confirmed definition: "Aggregate stream size in bytes for all files... Excludes version, metadata, list item attachment, and non-customized document sizes"
- [SearchExecutor Class — MS Learn CSOM reference](https://learn.microsoft.com/en-us/dotnet/api/microsoft.sharepoint.client.search.query.searchexecutor?view=sharepoint-csom) — namespace `Microsoft.SharePoint.Client.Search.Query`, assembly `Microsoft.SharePoint.Client.Search.Portable.dll`
- [Search limits for SharePoint — MS Learn](https://learn.microsoft.com/en-us/sharepoint/search-limits) — StartRow max 50,000 (boundary), RowLimit max 500 (boundary) confirmed
- [SharepointToolbox/bin/Debug output] — `Microsoft.SharePoint.Client.Search.dll` confirmed present as transitive dep
### Secondary (MEDIUM confidence)
- [Load storage metric from SPO — longnlp.github.io](https://longnlp.github.io/load-storage-metric-from-SPO) — CSOM Load pattern: `ctx.Load(folder, f => f.StorageMetrics)` verified
- [Fetch all results from SharePoint Search using CSOM — usefulscripts.wordpress.com](https://usefulscripts.wordpress.com/2015/09/11/how-to-fetch-all-results-from-sharepoint-search-using-dot-net-managed-csom/) — KeywordQuery + SearchExecutor pagination pattern with StartRow; confirmed against official docs
- PowerShell reference `Sharepoint_ToolBox.ps1` lines 1621-1780 (Export-StorageToHTML), 2112-2233 (Export-SearchResultsToHTML), 2235-2406 (Export-DuplicatesToHTML), 4432-4534 (storage scan), 4747-4808 (file search), 4937-5059 (duplicate scan) — authoritative reference implementation
### Tertiary (LOW confidence — implementation detail, verify when coding)
- [SharePoint CSOM Q&A — Getting size of subsite](https://learn.microsoft.com/en-us/answers/questions/1518977/getting-size-of-a-subsite-using-csom) — general pattern confirmed; specific edge cases not verified
- [Pagination for large result sets — MS Learn](https://learn.microsoft.com/en-us/sharepoint/dev/general-development/pagination-for-large-result-sets) — DocId-based pagination beyond 50k exists but is not needed for Phase 3
---
## Metadata
**Confidence breakdown:**
- Standard Stack: HIGH — no new packages needed; Search.dll confirmed present; all APIs verified against MS docs
- Architecture Patterns: HIGH — direct port of working PS reference; CSOM API shapes confirmed
- Pitfalls: HIGH for StorageMetrics loading, search result typing, vti_history filter (all from PS reference or official docs); MEDIUM for KQL length limit (documented but not commonly hit)
- Localization keys: HIGH — directly extracted from PS reference lines 2747-2813
**Research date:** 2026-04-02
**Valid until:** 2026-07-01 (CSOM APIs stable; SharePoint search limits stable; re-verify if PnP.Framework upgrades past 1.18)