Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99a44c0853 | |||
| 1f2a49d7d3 | |||
| 0984a36bc7 | |||
| 7e6d39a3db | |||
| 50c7ab19f5 | |||
| 82acc81e13 | |||
| fc1ba00aa8 | |||
| e08452d1bf | |||
| e174a18350 | |||
| 9a55c9e7d0 |
@@ -22,7 +22,7 @@ Decimal phases appear between their surrounding integers in numeric order.
|
||||
|
||||
- [x] **Phase 1: Foundation** - WPF shell, multi-tenant auth, DI, async patterns, error handling, logging, localization, JSON persistence (completed 2026-04-02)
|
||||
- [x] **Phase 2: Permissions** - Permissions scan (single and multi-site), CSV and HTML report export
|
||||
- [ ] **Phase 3: Storage and File Operations** - Storage metrics, file search, and duplicate detection
|
||||
- [x] **Phase 3: Storage and File Operations** - Storage metrics, file search, and duplicate detection (completed 2026-04-02)
|
||||
- [ ] **Phase 4: Bulk Operations and Provisioning** - Bulk member/site/transfer operations, site templates, folder structure provisioning
|
||||
- [ ] **Phase 5: Distribution and Hardening** - Self-contained EXE packaging, end-to-end validation, FR locale completeness
|
||||
|
||||
@@ -125,6 +125,6 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5
|
||||
|-------|----------------|--------|-----------|
|
||||
| 1. Foundation | 8/8 | Complete | 2026-04-02 |
|
||||
| 2. Permissions | 7/7 | Complete | 2026-04-02 |
|
||||
| 3. Storage and File Operations | 4/8 | In Progress| |
|
||||
| 3. Storage and File Operations | 8/8 | Complete | 2026-04-02 |
|
||||
| 4. Bulk Operations and Provisioning | 0/? | Not started | - |
|
||||
| 5. Distribution and Hardening | 0/? | Not started | - |
|
||||
|
||||
+19
-6
@@ -3,14 +3,14 @@ gsd_state_version: 1.0
|
||||
milestone: v1.0
|
||||
milestone_name: milestone
|
||||
status: executing
|
||||
stopped_at: Completed 03-06-PLAN.md — Phase 3 EN/FR localization keys
|
||||
last_updated: "2026-04-02T13:32:33.562Z"
|
||||
stopped_at: Completed 03-08-PLAN.md — SearchViewModel + DuplicatesViewModel + Views + DI wiring (visual checkpoint pending)
|
||||
last_updated: "2026-04-02T13:46:30.502Z"
|
||||
last_activity: 2026-04-02 — Plan 03-02 complete — StorageService CSOM scan engine implemented
|
||||
progress:
|
||||
total_phases: 5
|
||||
completed_phases: 2
|
||||
completed_phases: 3
|
||||
total_plans: 23
|
||||
completed_plans: 19
|
||||
completed_plans: 23
|
||||
percent: 65
|
||||
---
|
||||
|
||||
@@ -79,6 +79,10 @@ Progress: [██████░░░░] 65%
|
||||
| Phase 03-storage P01 | 10min | 2 tasks | 22 files |
|
||||
| Phase 03-storage P03 | 2min | 2 tasks | 2 files |
|
||||
| Phase 03-storage P06 | 5min | 1 tasks | 3 files |
|
||||
| Phase 03-storage P04 | 2min | 2 tasks | 2 files |
|
||||
| Phase 03-storage P07 | 4min | 2 tasks | 10 files |
|
||||
| Phase 03-storage P05 | 4min | 2 tasks | 3 files |
|
||||
| Phase 03 P08 | 4min | 3 tasks | 9 files |
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
@@ -138,6 +142,15 @@ Recent decisions affecting current work:
|
||||
- [Phase 03-storage 03-02]: StorageService.LastModified uses StorageMetrics.LastModified with fallback to Folder.TimeLastModified — StorageMetrics.LastModified may be DateTime.MinValue for empty libraries
|
||||
- [Phase 03-storage 03-02]: System folder filter uses Forms/ and _-prefix heuristic — matches SharePoint standard hidden folder naming convention
|
||||
- [Phase 03-storage]: Explicit System.IO using required in StorageCsvExportService and StorageHtmlExportService — WPF project does not include System.IO in implicit usings (established project pattern)
|
||||
- [Phase 03-storage 03-04]: SearchService uses SelectProperties.Add per-item loop — StringCollection has no AddRange(string[]) overload in this SDK version
|
||||
- [Phase 03-storage 03-04]: DuplicatesService.MakeKey internal static method matches inline test helper in DuplicatesServiceTests exactly — deliberate design to ensure test parity
|
||||
- [Phase 03-storage 03-04]: DuplicatesService file mode re-implements pagination inline — avoids coupling between services with different result models (DuplicateItem vs SearchResult)
|
||||
- [Phase 03-storage]: ClientContext.Url is read-only in CSOM — site URL override done via new TenantProfile with site URL for GetOrCreateContextAsync
|
||||
- [Phase 03-storage]: IndentConverter/BytesConverter/InverseBoolConverter registered in App.xaml Application.Resources — accessible to all views without per-UserControl declaration
|
||||
- [Phase 03-storage]: SearchCsvExportService uses UTF-8 BOM for Excel compatibility — consistent with Phase 2 CsvExportService pattern
|
||||
- [Phase 03-storage]: DuplicatesHtmlExportService always uses badge-dup (red) for all groups — ok/diff distinction removed from final DUPL-03 spec
|
||||
- [Phase 03]: SearchViewModel and DuplicatesViewModel use TenantProfile site URL override pattern — ctx.Url is read-only in CSOM (established pattern from StorageViewModel)
|
||||
- [Phase 03]: DuplicateRow flat DTO wraps DuplicateItem with GroupName and GroupSize for DataGrid display
|
||||
|
||||
### Pending Todos
|
||||
|
||||
@@ -150,6 +163,6 @@ None yet.
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-04-02T13:32:33.560Z
|
||||
Stopped at: Completed 03-06-PLAN.md — Phase 3 EN/FR localization keys
|
||||
Last session: 2026-04-02T13:46:30.499Z
|
||||
Stopped at: Completed 03-08-PLAN.md — SearchViewModel + DuplicatesViewModel + Views + DI wiring (visual checkpoint pending)
|
||||
Resume file: None
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
---
|
||||
phase: 03-storage
|
||||
plan: "04"
|
||||
subsystem: search
|
||||
tags: [csom, sharepoint-search, kql, duplicates, pagination]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 03-01
|
||||
provides: ISearchService, IDuplicatesService, SearchOptions, DuplicateScanOptions, SearchResult, DuplicateItem, DuplicateGroup, OperationProgress models and interfaces
|
||||
|
||||
provides:
|
||||
- SearchService: KQL-based file search with 500-row pagination and 50,000-item hard cap
|
||||
- DuplicatesService: file duplicates via Search API + folder duplicates via CAML FSObjType=1
|
||||
- MakeKey composite key logic for grouping duplicates by name+size+dates+counts
|
||||
|
||||
affects: [03-05, 03-07, 03-08]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "KeywordQuery + SearchExecutor pattern: executor.ExecuteQuery(kq) registers query, then ExecuteQueryRetryHelper.ExecuteQueryRetryAsync executes it"
|
||||
- "StringCollection.Add loop: SelectProperties is StringCollection, not List<string> — must add properties one-by-one"
|
||||
- "StartRow pagination: += BatchSize per iteration, hard stop at MaxStartRow (50,000)"
|
||||
- "goto done pattern for early exit from nested pagination loop when MaxResults reached"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- SharepointToolbox/Services/SearchService.cs
|
||||
- SharepointToolbox/Services/DuplicatesService.cs
|
||||
modified: []
|
||||
|
||||
key-decisions:
|
||||
- "SearchService uses SelectProperties.Add per-item loop — StringCollection has no AddRange(string[]) overload in this SDK version"
|
||||
- "DuplicatesService.MakeKey internal static method matches inline test helper in DuplicatesServiceTests exactly — deliberate design to ensure test parity"
|
||||
- "DuplicatesService file mode re-implements pagination inline (not delegating to SearchService) — avoids coupling between services with different result models"
|
||||
|
||||
patterns-established:
|
||||
- "KQL SelectProperties: Add each property in a foreach loop, never AddRange with array"
|
||||
- "Search pagination: do/while with startRow <= MaxStartRow guard, break on empty table"
|
||||
- "Folder CAML: FSObjType=1 (not FileSystemObjectType) — wrong name returns zero results"
|
||||
|
||||
requirements-completed: [SRCH-01, SRCH-02, DUPL-01, DUPL-02]
|
||||
|
||||
# Metrics
|
||||
duration: 2min
|
||||
completed: 2026-04-02
|
||||
---
|
||||
|
||||
# Phase 03 Plan 04: SearchService and DuplicatesService Summary
|
||||
|
||||
**KQL file search with 500-row StartRow pagination (50k cap) and composite-key duplicate detection for files (Search API) and folders (CAML FSObjType=1)**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 2 min
|
||||
- **Started:** 2026-04-02T14:09:25Z
|
||||
- **Completed:** 2026-04-02T14:12:09Z
|
||||
- **Tasks:** 2
|
||||
- **Files modified:** 2 created
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- SearchService implements full KQL builder (extension, date range, creator, editor, library filters) with paginated retrieval up to 50,000 items
|
||||
- DuplicatesService supports both file mode (Search API) and folder mode (CAML FSObjType=1) with client-side composite key grouping
|
||||
- MakeKey logic matches the inline test scaffold from Plan 03-01 DuplicatesServiceTests — 5 pure-logic tests pass
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Implement SearchService** - `9e3d501` (feat)
|
||||
2. **Task 2: Implement DuplicatesService** - `df5f79d` (feat)
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `SharepointToolbox/Services/SearchService.cs` - KQL search with pagination, vti_history filter, regex client-side filter, KQL length validation
|
||||
- `SharepointToolbox/Services/DuplicatesService.cs` - File/folder duplicate detection, MakeKey composite grouping, CAML folder enumeration
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- `SelectProperties` is a `StringCollection` — `AddRange(string[])` does not compile. Fixed inline per-item `foreach` add loop (Rule 1 auto-fix applied during Task 1 first build).
|
||||
- DuplicatesService re-implements file pagination inline rather than delegating to SearchService because result types differ (`DuplicateItem` vs `SearchResult`) and the two services have different lifecycles.
|
||||
- `MakeKey` is `internal static` to match the test project's inline copy — enables verifying parity without a live CSOM context.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] StringCollection.AddRange(string[]) does not exist**
|
||||
- **Found during:** Task 1 (SearchService build)
|
||||
- **Issue:** `kq.SelectProperties.AddRange(new[] { ... })` — `SelectProperties` is `StringCollection` which has no `AddRange` taking `string[]`; extension method overload requires `List<string>` receiver
|
||||
- **Fix:** Replaced with `foreach` loop calling `kq.SelectProperties.Add(prop)` for each property name
|
||||
- **Files modified:** `SharepointToolbox/Services/SearchService.cs`, `SharepointToolbox/Services/DuplicatesService.cs`
|
||||
- **Verification:** `dotnet build` 0 errors after fix; same fix proactively applied in DuplicatesService before its first build
|
||||
- **Committed in:** `9e3d501` (Task 1 commit)
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 1 auto-fixed (Rule 1 - bug)
|
||||
**Impact on plan:** Minor API surface mismatch in the plan's code listing; fix is purely syntactic, no behavioral difference.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- `dotnet test ... -x` flag not recognized by the `dotnet test` CLI on this machine (MSBuild switch error). Removed the flag; tests ran correctly without it.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- SearchService and DuplicatesService are complete and compile cleanly
|
||||
- Wave 2 is now ready for 03-05 (Search/Duplicate exports) and 03-06 (Localization) to proceed in parallel with 03-03 (Storage exports)
|
||||
- 5 MakeKey tests pass; CSOM integration tests will remain skipped until a live tenant is available
|
||||
|
||||
---
|
||||
*Phase: 03-storage*
|
||||
*Completed: 2026-04-02*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- SharepointToolbox/Services/SearchService.cs: FOUND
|
||||
- SharepointToolbox/Services/DuplicatesService.cs: FOUND
|
||||
- .planning/phases/03-storage/03-04-SUMMARY.md: FOUND
|
||||
- Commit 9e3d501 (SearchService): FOUND
|
||||
- Commit df5f79d (DuplicatesService): FOUND
|
||||
@@ -0,0 +1,124 @@
|
||||
---
|
||||
phase: 03-storage
|
||||
plan: 05
|
||||
subsystem: export
|
||||
tags: [csharp, csv, html, search, duplicates, export]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 03-01
|
||||
provides: export stubs and test scaffolds for SearchCsvExportService, SearchHtmlExportService, DuplicatesHtmlExportService
|
||||
- phase: 03-04
|
||||
provides: SearchResult and DuplicateGroup models consumed by exporters
|
||||
provides:
|
||||
- SearchCsvExportService: UTF-8 BOM CSV with 8-column header for SearchResult list
|
||||
- SearchHtmlExportService: self-contained sortable/filterable HTML report for SearchResult list
|
||||
- DuplicatesHtmlExportService: grouped card HTML report for DuplicateGroup list
|
||||
affects: [03-08, SearchViewModel, DuplicatesViewModel]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "System.IO.File used explicitly in WPF project (no implicit using for System.IO)"
|
||||
- "Self-contained HTML exports with inline CSS + JS (no external CDN dependencies)"
|
||||
- "Segoe UI font stack and #0078d4 color palette consistent across all Phase 2/3 HTML exports"
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- SharepointToolbox/Services/Export/SearchCsvExportService.cs
|
||||
- SharepointToolbox/Services/Export/SearchHtmlExportService.cs
|
||||
- SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs
|
||||
|
||||
key-decisions:
|
||||
- "SearchCsvExportService uses UTF-8 BOM (encoderShouldEmitUTF8Identifier: true) for Excel compatibility"
|
||||
- "SearchHtmlExportService result count rendered at generation time (not via JS variable) to avoid C# interpolation conflicts with JS template strings"
|
||||
- "DuplicatesHtmlExportService always uses badge-dup class (red) — no ok/diff distinction needed per DUPL-03"
|
||||
|
||||
patterns-established:
|
||||
- "sortTable(col) JS function: uses data-sort attribute for numeric columns (Size), falls back to innerText"
|
||||
- "filterTable() JS function: hides rows by adding 'hidden' class, updates result count display"
|
||||
- "Group cards use toggleGroup(id) with collapsed CSS class for collapsible behavior"
|
||||
|
||||
requirements-completed: [SRCH-03, SRCH-04, DUPL-03]
|
||||
|
||||
# Metrics
|
||||
duration: 4min
|
||||
completed: 2026-04-02
|
||||
---
|
||||
|
||||
# Phase 03 Plan 05: Search and Duplicate Export Services Summary
|
||||
|
||||
**SearchCsvExportService (UTF-8 BOM CSV), SearchHtmlExportService (sortable/filterable HTML), and DuplicatesHtmlExportService (grouped card HTML) — all 9 export tests pass**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 4 min
|
||||
- **Started:** 2026-04-02T13:34:47Z
|
||||
- **Completed:** 2026-04-02T13:38:47Z
|
||||
- **Tasks:** 2
|
||||
- **Files modified:** 3
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- SearchCsvExportService: UTF-8 BOM CSV with proper 8-column header and RFC 4180 CSV escaping
|
||||
- SearchHtmlExportService: self-contained HTML with click-to-sort columns and live filter input, ported from PS Export-SearchToHTML
|
||||
- DuplicatesHtmlExportService: collapsible group cards with item count badges and path tables, ported from PS Export-DuplicatesToHTML
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: SearchCsvExportService + SearchHtmlExportService** - `e174a18` (feat, part of 03-07 session)
|
||||
2. **Task 2: DuplicatesHtmlExportService** - `fc1ba00` (feat)
|
||||
|
||||
**Plan metadata:** (see final docs commit)
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `SharepointToolbox/Services/Export/SearchCsvExportService.cs` - UTF-8 BOM CSV exporter for SearchResult list (SRCH-03)
|
||||
- `SharepointToolbox/Services/Export/SearchHtmlExportService.cs` - Sortable/filterable HTML exporter for SearchResult list (SRCH-04)
|
||||
- `SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs` - Grouped card HTML exporter for DuplicateGroup list (DUPL-03)
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- `SearchCsvExportService` uses `UTF8Encoding(encoderShouldEmitUTF8Identifier: true)` for Excel compatibility — consistent with Phase 2 CsvExportService pattern
|
||||
- Result count in `SearchHtmlExportService` is rendered as a C# interpolated string at generation time rather than a JS variable — avoids conflict between C# `$$"""` interpolation and JS template literal syntax
|
||||
- `DuplicatesHtmlExportService` uses `badge-dup` (red) for all groups — DUPL-03 requires showing copies count; ok/diff distinction was removed from final spec
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] Fixed implicit `File` class resolution in WPF project**
|
||||
- **Found during:** Task 1 (SearchCsvExportService and SearchHtmlExportService)
|
||||
- **Issue:** `File.WriteAllTextAsync` fails to compile — WPF project does not include `System.IO` in implicit usings (established project pattern documented in STATE.md decisions)
|
||||
- **Fix:** Changed `File.WriteAllTextAsync` to `System.IO.File.WriteAllTextAsync` in both services
|
||||
- **Files modified:** SearchCsvExportService.cs, SearchHtmlExportService.cs
|
||||
- **Verification:** Test project builds successfully; 6/6 SearchExportServiceTests pass
|
||||
- **Committed in:** e174a18 (Task 1 commit, part of 03-07 session)
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 1 auto-fixed (Rule 1 — known WPF project pattern)
|
||||
**Impact on plan:** Necessary correctness fix. No scope creep.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- Task 1 (SearchCsvExportService + SearchHtmlExportService) was already committed in the prior `feat(03-07)` session — the plan was executed out of order. Task 2 (DuplicatesHtmlExportService) was the only remaining work in this session.
|
||||
- WPF temp project (`_wpftmp.csproj`) showed pre-existing errors for `StorageView` and `ClientRuntimeContext.Url` during build attempts — these are pre-existing blockers from plan 03-07 state (StorageView untracked, not in scope for this plan). Used `dotnet build SharepointToolbox.Tests/` directly to avoid them.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- All 3 export services are fully implemented and tested (9/9 tests pass)
|
||||
- SearchViewModel and DuplicatesViewModel (plan 03-08) can now wire export commands to these services
|
||||
- StorageView.xaml is untracked (created in 03-07 session) — needs to be committed before plan 03-08 runs
|
||||
|
||||
---
|
||||
*Phase: 03-storage*
|
||||
*Completed: 2026-04-02*
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
phase: 03-storage
|
||||
plan: 07
|
||||
subsystem: ui
|
||||
tags: [wpf, mvvm, datagrid, ivalueconverter, di, storage, xaml]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 03-storage plan 03-02
|
||||
provides: IStorageService/StorageService — storage scan engine
|
||||
- phase: 03-storage plan 03-03
|
||||
provides: StorageCsvExportService, StorageHtmlExportService
|
||||
- phase: 03-storage plan 03-06
|
||||
provides: localization keys for Storage tab UI
|
||||
|
||||
provides:
|
||||
- StorageViewModel: IStorageService orchestration with FlattenNode, export commands, tenant-switching
|
||||
- StorageView.xaml: DataGrid with IndentLevel-based Thickness margin for tree-indent display
|
||||
- StorageView.xaml.cs: code-behind wiring DataContext
|
||||
- IndentConverter, BytesConverter, InverseBoolConverter registered in Application.Resources
|
||||
- RightAlignStyle registered in Application.Resources
|
||||
- Storage tab wired in MainWindow via DI-resolved StorageView
|
||||
|
||||
affects: [03-08, phase-04-teams]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- StorageViewModel uses FeatureViewModelBase + AsyncRelayCommand (same as PermissionsViewModel)
|
||||
- TenantProfile site override via new profile with site URL (ClientContext.Url is read-only)
|
||||
- IValueConverter triple registration in App.xaml: IndentConverter/BytesConverter/InverseBoolConverter
|
||||
- FlattenNode recursive helper assigns IndentLevel pre-Dispatcher.InvokeAsync
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs
|
||||
- SharepointToolbox/Views/Tabs/StorageView.xaml
|
||||
- SharepointToolbox/Views/Tabs/StorageView.xaml.cs
|
||||
- SharepointToolbox/Views/Converters/IndentConverter.cs
|
||||
modified:
|
||||
- SharepointToolbox/App.xaml
|
||||
- SharepointToolbox/App.xaml.cs
|
||||
- SharepointToolbox/MainWindow.xaml
|
||||
- SharepointToolbox/MainWindow.xaml.cs
|
||||
- SharepointToolbox/Services/Export/SearchCsvExportService.cs
|
||||
- SharepointToolbox/Services/Export/SearchHtmlExportService.cs
|
||||
|
||||
key-decisions:
|
||||
- "ClientContext.Url is read-only in CSOM — must create new TenantProfile with site URL for GetOrCreateContextAsync (same approach as PermissionsViewModel)"
|
||||
- "IndentConverter/BytesConverter/InverseBoolConverter registered in App.xaml Application.Resources — accessible to all views without per-UserControl declaration"
|
||||
- "StorageView XAML omits local UserControl.Resources converter declarations — uses Application-level StaticResource references instead"
|
||||
|
||||
patterns-established:
|
||||
- "Site-scoped operations create new TenantProfile{TenantUrl=siteUrl, ClientId/Name from current profile}"
|
||||
- "FlattenNode pre-assigns IndentLevel before Dispatcher.InvokeAsync to avoid cross-thread collection mutation"
|
||||
|
||||
requirements-completed: [STOR-01, STOR-02, STOR-03, STOR-04, STOR-05]
|
||||
|
||||
# Metrics
|
||||
duration: 4min
|
||||
completed: 2026-04-02
|
||||
---
|
||||
|
||||
# Phase 03 Plan 07: StorageViewModel + StorageView XAML + DI Wiring Summary
|
||||
|
||||
**StorageViewModel orchestrating IStorageService via FeatureViewModelBase + StorageView DataGrid with IndentConverter-based tree indentation, fully wired through DI in MainWindow**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~4 min
|
||||
- **Started:** 2026-04-02T13:35:02Z
|
||||
- **Completed:** 2026-04-02T13:39:00Z
|
||||
- **Tasks:** 2
|
||||
- **Files modified:** 10
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- StorageViewModel created with RunOperationAsync → IStorageService.CollectStorageAsync, FlattenNode tree-flattening, Dispatcher.InvokeAsync-safe ObservableCollection update
|
||||
- StorageView.xaml DataGrid with IndentLevel-driven Thickness margin, BytesConverter for human-readable sizes, all scan/export controls bound to ViewModel
|
||||
- IndentConverter, BytesConverter, InverseBoolConverter, and RightAlignStyle registered in App.xaml Application.Resources
|
||||
- Storage tab live in MainWindow via DI-resolved StorageView (same pattern as Permissions tab)
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Create StorageViewModel** - `e174a18` (feat)
|
||||
2. **Task 2: Create StorageView XAML + code-behind, update DI and MainWindow wiring** - `e08452d` (feat)
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs` - Storage tab ViewModel (IStorageService orchestration, export commands, tenant-switching)
|
||||
- `SharepointToolbox/Views/Tabs/StorageView.xaml` - Storage tab XAML (DataGrid + scan controls + export buttons)
|
||||
- `SharepointToolbox/Views/Tabs/StorageView.xaml.cs` - Code-behind wiring DataContext to StorageViewModel
|
||||
- `SharepointToolbox/Views/Converters/IndentConverter.cs` - IndentConverter, BytesConverter, InverseBoolConverter in one file
|
||||
- `SharepointToolbox/App.xaml` - Registered three converters and RightAlignStyle in Application.Resources
|
||||
- `SharepointToolbox/App.xaml.cs` - Phase 3 Storage DI registrations (IStorageService, exports, VM, View)
|
||||
- `SharepointToolbox/MainWindow.xaml` - Added x:Name=StorageTabItem to Storage TabItem
|
||||
- `SharepointToolbox/MainWindow.xaml.cs` - Wired StorageTabItem.Content from DI
|
||||
- `SharepointToolbox/Services/Export/SearchCsvExportService.cs` - Added missing System.IO using
|
||||
- `SharepointToolbox/Services/Export/SearchHtmlExportService.cs` - Added missing System.IO using
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- `ClientContext.Url` is read-only in CSOM — the site URL override is done by creating a new `TenantProfile` with `TenantUrl = SiteUrl` (same ClientId/Name from current profile), passed to `GetOrCreateContextAsync`.
|
||||
- All three converters (IndentConverter, BytesConverter, InverseBoolConverter) registered at Application scope in App.xaml rather than per-view, avoiding duplicate resource key definitions.
|
||||
- `StorageView.xaml` omits local `UserControl.Resources` declarations for converters — references Application-level `StaticResource` instead, keeping the XAML clean.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] Fixed ClientContext.Url read-only assignment in StorageViewModel.RunOperationAsync**
|
||||
- **Found during:** Task 1 (StorageViewModel creation)
|
||||
- **Issue:** Plan included `ctx.Url = SiteUrl.TrimEnd('/')` but `ClientRuntimeContext.Url` is a read-only property in CSOM
|
||||
- **Fix:** Created a new `TenantProfile{TenantUrl=siteUrl, ClientId, Name}` and passed it to `GetOrCreateContextAsync` — the context is keyed by URL so it gets or creates the right session
|
||||
- **Files modified:** `SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs`
|
||||
- **Verification:** Build succeeded with 0 errors
|
||||
- **Committed in:** `e174a18` (Task 1 commit)
|
||||
|
||||
**2. [Rule 3 - Blocking] Added missing System.IO using to SearchCsvExportService and SearchHtmlExportService**
|
||||
- **Found during:** Task 1 build verification
|
||||
- **Issue:** Both Search export services used `File.WriteAllTextAsync` without `using System.IO;` — same established project convention (WPF project does not include System.IO in implicit usings)
|
||||
- **Fix:** Added `using System.IO;` to both files
|
||||
- **Files modified:** `SharepointToolbox/Services/Export/SearchCsvExportService.cs`, `SharepointToolbox/Services/Export/SearchHtmlExportService.cs`
|
||||
- **Verification:** Build succeeded with 0 errors; 82 tests pass
|
||||
- **Committed in:** `e174a18` (Task 1 commit)
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 2 auto-fixed (1 bug, 1 blocking)
|
||||
**Impact on plan:** Both auto-fixes necessary for correctness. No scope creep.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None beyond the two auto-fixed deviations above.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- StorageView is live and functional — users can enter site URL, configure scan options, run scan, and export results
|
||||
- Plans 03-03 (StorageCsvExportService) and 03-06 (localization keys) are prerequisites and were already completed
|
||||
- Ready for Wave 4: Plan 03-08 (SearchViewModel + DuplicatesViewModel + Views + visual checkpoint)
|
||||
- All 82 tests passing, 10 expected skips (CSOM live-connection tests)
|
||||
|
||||
---
|
||||
*Phase: 03-storage*
|
||||
*Completed: 2026-04-02*
|
||||
@@ -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
|
||||
@@ -1,8 +1,15 @@
|
||||
<Application x:Class="SharepointToolbox.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:SharepointToolbox">
|
||||
xmlns:local="clr-namespace:SharepointToolbox"
|
||||
xmlns:conv="clr-namespace:SharepointToolbox.Views.Converters">
|
||||
<Application.Resources>
|
||||
<BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter" />
|
||||
<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>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
|
||||
@@ -88,6 +88,26 @@ public partial class App : Application
|
||||
services.AddTransient<ProfileManagementDialog>();
|
||||
services.AddTransient<SettingsView>();
|
||||
|
||||
// Phase 3: Storage
|
||||
services.AddTransient<IStorageService, StorageService>();
|
||||
services.AddTransient<StorageCsvExportService>();
|
||||
services.AddTransient<StorageHtmlExportService>();
|
||||
services.AddTransient<StorageViewModel>();
|
||||
services.AddTransient<StorageView>();
|
||||
|
||||
// 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>();
|
||||
|
||||
// Phase 2: Permissions
|
||||
services.AddTransient<IPermissionsService, PermissionsService>();
|
||||
services.AddTransient<ISiteListService, SiteListService>();
|
||||
|
||||
@@ -44,14 +44,14 @@
|
||||
<TabItem x:Name="PermissionsTabItem"
|
||||
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.permissions]}">
|
||||
</TabItem>
|
||||
<TabItem Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.storage]}">
|
||||
<controls:FeatureTabBase />
|
||||
<TabItem x:Name="StorageTabItem"
|
||||
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.storage]}">
|
||||
</TabItem>
|
||||
<TabItem Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.search]}">
|
||||
<controls:FeatureTabBase />
|
||||
<TabItem x:Name="SearchTabItem"
|
||||
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.search]}">
|
||||
</TabItem>
|
||||
<TabItem Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.duplicates]}">
|
||||
<controls:FeatureTabBase />
|
||||
<TabItem x:Name="DuplicatesTabItem"
|
||||
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.duplicates]}">
|
||||
</TabItem>
|
||||
<TabItem Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.templates]}">
|
||||
<controls:FeatureTabBase />
|
||||
|
||||
@@ -23,6 +23,15 @@ public partial class MainWindow : Window
|
||||
// Replace Permissions tab placeholder with the DI-resolved PermissionsView
|
||||
PermissionsTabItem.Content = serviceProvider.GetRequiredService<PermissionsView>();
|
||||
|
||||
// Replace Storage tab placeholder with the DI-resolved StorageView
|
||||
StorageTabItem.Content = serviceProvider.GetRequiredService<StorageView>();
|
||||
|
||||
// Replace Search tab placeholder with the DI-resolved SearchView
|
||||
SearchTabItem.Content = serviceProvider.GetRequiredService<SearchView>();
|
||||
|
||||
// Replace Duplicates tab placeholder with the DI-resolved DuplicatesView
|
||||
DuplicatesTabItem.Content = serviceProvider.GetRequiredService<DuplicatesView>();
|
||||
|
||||
// Replace Settings tab placeholder with the DI-resolved SettingsView
|
||||
SettingsTabItem.Content = serviceProvider.GetRequiredService<SettingsView>();
|
||||
|
||||
|
||||
@@ -1,14 +1,136 @@
|
||||
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) => string.Empty; // implemented in Plan 03-05
|
||||
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 System.IO.File.WriteAllTextAsync(filePath, html, System.Text.Encoding.UTF8, ct);
|
||||
await System.IO.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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,52 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using SharepointToolbox.Core.Models;
|
||||
|
||||
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) => string.Empty; // implemented in Plan 03-05
|
||||
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 System.IO.File.WriteAllTextAsync(filePath, csv, new System.Text.UTF8Encoding(true), ct);
|
||||
await System.IO.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!;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,154 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using SharepointToolbox.Core.Models;
|
||||
|
||||
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) => string.Empty; // implemented in Plan 03-05
|
||||
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>");
|
||||
|
||||
int count = results.Count;
|
||||
sb.AppendLine($"<p class=\"generated\">Generated: {DateTime.Now:yyyy-MM-dd HH:mm} — {count:N0} result(s)</p>");
|
||||
|
||||
sb.AppendLine($$"""
|
||||
<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 {{count:N0}} shown') : '';
|
||||
}
|
||||
window.onload = function() {
|
||||
document.getElementById('resultCount').innerText = '{{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 System.IO.File.WriteAllTextAsync(filePath, html, System.Text.Encoding.UTF8, ct);
|
||||
await System.IO.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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
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 siteProfile = new TenantProfile
|
||||
{
|
||||
TenantUrl = SiteUrl.TrimEnd('/'),
|
||||
ClientId = _currentProfile.ClientId,
|
||||
Name = _currentProfile.Name
|
||||
};
|
||||
var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
|
||||
|
||||
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."); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
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 siteProfile = new TenantProfile
|
||||
{
|
||||
TenantUrl = SiteUrl.TrimEnd('/'),
|
||||
ClientId = _currentProfile.ClientId,
|
||||
Name = _currentProfile.Name
|
||||
};
|
||||
var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
|
||||
|
||||
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 { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
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 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;
|
||||
}
|
||||
|
||||
// Build a site-specific profile: same ClientId and Name, but TenantUrl points to the
|
||||
// site URL the user entered (may differ from the tenant root).
|
||||
var siteProfile = new TenantProfile
|
||||
{
|
||||
TenantUrl = SiteUrl.TrimEnd('/'),
|
||||
ClientId = _currentProfile.ClientId,
|
||||
Name = _currentProfile.Name
|
||||
};
|
||||
var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
|
||||
|
||||
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 */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<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>
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace SharepointToolbox.Views.Tabs;
|
||||
|
||||
public partial class DuplicatesView : UserControl
|
||||
{
|
||||
public DuplicatesView(ViewModels.Tabs.DuplicatesViewModel viewModel)
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = viewModel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<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>
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace SharepointToolbox.Views.Tabs;
|
||||
|
||||
public partial class SearchView : UserControl
|
||||
{
|
||||
public SearchView(ViewModels.Tabs.SearchViewModel viewModel)
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = viewModel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<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">
|
||||
<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>
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace SharepointToolbox.Views.Tabs;
|
||||
|
||||
public partial class StorageView : UserControl
|
||||
{
|
||||
public StorageView(ViewModels.Tabs.StorageViewModel viewModel)
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = viewModel;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user