chore: complete v1.0 milestone
All checks were successful
Release zip package / release (push) Successful in 10s
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:
815
.planning/milestones/v1.0-phases/03-storage/03-01-PLAN.md
Normal file
815
.planning/milestones/v1.0-phases/03-storage/03-01-PLAN.md
Normal 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`
|
||||
141
.planning/milestones/v1.0-phases/03-storage/03-01-SUMMARY.md
Normal file
141
.planning/milestones/v1.0-phases/03-storage/03-01-SUMMARY.md
Normal 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*
|
||||
246
.planning/milestones/v1.0-phases/03-storage/03-02-PLAN.md
Normal file
246
.planning/milestones/v1.0-phases/03-storage/03-02-PLAN.md
Normal 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`
|
||||
94
.planning/milestones/v1.0-phases/03-storage/03-02-SUMMARY.md
Normal file
94
.planning/milestones/v1.0-phases/03-storage/03-02-SUMMARY.md
Normal 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)
|
||||
340
.planning/milestones/v1.0-phases/03-storage/03-03-PLAN.md
Normal file
340
.planning/milestones/v1.0-phases/03-storage/03-03-PLAN.md
Normal 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})\">▶</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})\">▶</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`
|
||||
126
.planning/milestones/v1.0-phases/03-storage/03-03-SUMMARY.md
Normal file
126
.planning/milestones/v1.0-phases/03-storage/03-03-SUMMARY.md
Normal 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*
|
||||
572
.planning/milestones/v1.0-phases/03-storage/03-04-PLAN.md
Normal file
572
.planning/milestones/v1.0-phases/03-storage/03-04-PLAN.md
Normal 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`
|
||||
128
.planning/milestones/v1.0-phases/03-storage/03-04-SUMMARY.md
Normal file
128
.planning/milestones/v1.0-phases/03-storage/03-04-SUMMARY.md
Normal 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
|
||||
459
.planning/milestones/v1.0-phases/03-storage/03-05-PLAN.md
Normal file
459
.planning/milestones/v1.0-phases/03-storage/03-05-PLAN.md
Normal 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`
|
||||
124
.planning/milestones/v1.0-phases/03-storage/03-05-SUMMARY.md
Normal file
124
.planning/milestones/v1.0-phases/03-storage/03-05-SUMMARY.md
Normal 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*
|
||||
301
.planning/milestones/v1.0-phases/03-storage/03-06-PLAN.md
Normal file
301
.planning/milestones/v1.0-phases/03-storage/03-06-PLAN.md
Normal 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`
|
||||
115
.planning/milestones/v1.0-phases/03-storage/03-06-SUMMARY.md
Normal file
115
.planning/milestones/v1.0-phases/03-storage/03-06-SUMMARY.md
Normal 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*
|
||||
577
.planning/milestones/v1.0-phases/03-storage/03-07-PLAN.md
Normal file
577
.planning/milestones/v1.0-phases/03-storage/03-07-PLAN.md
Normal 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`
|
||||
152
.planning/milestones/v1.0-phases/03-storage/03-07-SUMMARY.md
Normal file
152
.planning/milestones/v1.0-phases/03-storage/03-07-SUMMARY.md
Normal 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*
|
||||
792
.planning/milestones/v1.0-phases/03-storage/03-08-PLAN.md
Normal file
792
.planning/milestones/v1.0-phases/03-storage/03-08-PLAN.md
Normal 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`
|
||||
81
.planning/milestones/v1.0-phases/03-storage/03-08-SUMMARY.md
Normal file
81
.planning/milestones/v1.0-phases/03-storage/03-08-SUMMARY.md
Normal 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
|
||||
756
.planning/milestones/v1.0-phases/03-storage/03-RESEARCH.md
Normal file
756
.planning/milestones/v1.0-phases/03-storage/03-RESEARCH.md
Normal 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)
|
||||
Reference in New Issue
Block a user