docs(03-08): complete SearchViewModel + DuplicatesViewModel + Views plan — Phase 3 complete
- 3 tasks completed, 9 files created/modified - Visual checkpoint pending: all three Phase 3 tabs wired and ready for UI verification
This commit is contained in:
815
.planning/phases/03-storage/03-01-PLAN.md
Normal file
815
.planning/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`
|
||||
246
.planning/phases/03-storage/03-02-PLAN.md
Normal file
246
.planning/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`
|
||||
340
.planning/phases/03-storage/03-03-PLAN.md
Normal file
340
.planning/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`
|
||||
572
.planning/phases/03-storage/03-04-PLAN.md
Normal file
572
.planning/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`
|
||||
459
.planning/phases/03-storage/03-05-PLAN.md
Normal file
459
.planning/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`
|
||||
301
.planning/phases/03-storage/03-06-PLAN.md
Normal file
301
.planning/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`
|
||||
577
.planning/phases/03-storage/03-07-PLAN.md
Normal file
577
.planning/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`
|
||||
792
.planning/phases/03-storage/03-08-PLAN.md
Normal file
792
.planning/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/phases/03-storage/03-08-SUMMARY.md
Normal file
81
.planning/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
|
||||
Reference in New Issue
Block a user